feat(apps): per-app deploy/activity timeline

Every deploy across all four source kinds now writes a workload-scoped
event via a shared plugin.EmitDeployEvent helper (replacing the inline
emit duplicated in static/dockerfile, standardizing static's metadata
key site_id->workload_id, and adding emission to image+compose which
were silent). New indexed event_log.workload_id column, EventLogFilter
.WorkloadID, and GET /api/workloads/{id}/events (id pinned from path).

Frontend: a forge "Activity" panel on /apps/[id] reusing EventLogEntry,
live SSE prepend filtered by workload_id, load-more pagination, an
All/Errors severity filter, and a shared toEventLogEntry mapper. en/ru
i18n parity.

Security: compose's failure status emits a generic reason instead of raw
`docker compose up` output, which can echo app secrets and egresses to
operator webhooks (NotificationURL + event-trigger actions); full detail
stays only in the returned error. Rune-safe 256-rune status cap.

Reviewed: go + typescript APPROVE; security HIGH fixed.
This commit is contained in:
2026-05-29 13:51:17 +03:00
parent 3071cda512
commit 93b6911b34
19 changed files with 814 additions and 223 deletions
+16 -11
View File
@@ -7,12 +7,13 @@ import (
// EventLogFilter holds optional filters for listing event log entries.
type EventLogFilter struct {
Severity string // Filter by severity (info, warn, error).
Source string // Filter by source.
Since string // Only events created at or after this timestamp.
Until string // Only events created at or before this timestamp.
Limit int // Maximum number of results (default 50).
Offset int // Offset for pagination.
Severity string // Filter by severity (info, warn, error).
Source string // Filter by source.
WorkloadID string // Filter by owning workload (exact match).
Since string // Only events created at or after this timestamp.
Until string // Only events created at or before this timestamp.
Limit int // Maximum number of results (default 50).
Offset int // Offset for pagination.
}
// EventLogStats holds counts of event log entries by severity.
@@ -31,9 +32,9 @@ func (s *Store) InsertEvent(evt EventLog) (EventLog, error) {
}
result, err := s.db.Exec(
`INSERT INTO event_log (source, severity, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?)`,
evt.Source, evt.Severity, evt.Message, evt.Metadata, evt.CreatedAt,
`INSERT INTO event_log (source, workload_id, severity, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?)`,
evt.Source, evt.WorkloadID, evt.Severity, evt.Message, evt.Metadata, evt.CreatedAt,
)
if err != nil {
return EventLog{}, fmt.Errorf("insert event: %w", err)
@@ -81,6 +82,10 @@ func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
conditions = append(conditions, "source IN ("+strings.Join(placeholders, ",")+")")
}
}
if filter.WorkloadID != "" {
conditions = append(conditions, "workload_id = ?")
args = append(args, filter.WorkloadID)
}
if filter.Since != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, filter.Since)
@@ -90,7 +95,7 @@ func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
args = append(args, filter.Until)
}
query := "SELECT id, source, severity, message, metadata, created_at FROM event_log"
query := "SELECT id, source, workload_id, severity, message, metadata, created_at FROM event_log"
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
@@ -114,7 +119,7 @@ func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
events := []EventLog{}
for rows.Next() {
var evt EventLog
if err := rows.Scan(&evt.ID, &evt.Source, &evt.Severity, &evt.Message, &evt.Metadata, &evt.CreatedAt); err != nil {
if err := rows.Scan(&evt.ID, &evt.Source, &evt.WorkloadID, &evt.Severity, &evt.Message, &evt.Metadata, &evt.CreatedAt); err != nil {
return nil, fmt.Errorf("scan event: %w", err)
}
events = append(events, evt)
+120
View File
@@ -0,0 +1,120 @@
package store
import (
"testing"
)
func TestInsertEvent_RoundTripsWorkloadID(t *testing.T) {
s := newTestStore(t)
in := EventLog{
Source: "image",
WorkloadID: "wl-abc",
Severity: "info",
Message: "my-app: deployed",
Metadata: `{"workload_id":"wl-abc"}`,
}
saved, err := s.InsertEvent(in)
if err != nil {
t.Fatalf("InsertEvent: %v", err)
}
if saved.ID == 0 {
t.Fatal("expected non-zero ID after insert")
}
if saved.WorkloadID != "wl-abc" {
t.Fatalf("returned WorkloadID = %q, want %q", saved.WorkloadID, "wl-abc")
}
rows, err := s.ListEvents(EventLogFilter{WorkloadID: "wl-abc"})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if len(rows) != 1 {
t.Fatalf("got %d rows, want 1", len(rows))
}
got := rows[0]
if got.WorkloadID != "wl-abc" {
t.Errorf("WorkloadID = %q, want %q", got.WorkloadID, "wl-abc")
}
if got.Source != "image" || got.Severity != "info" || got.Message != "my-app: deployed" {
t.Errorf("round-trip mismatch: %+v", got)
}
}
func TestInsertEvent_DefaultsWorkloadIDToEmpty(t *testing.T) {
s := newTestStore(t)
// Non-deploy callers leave WorkloadID at its zero value; the column
// must accept "" (NOT NULL DEFAULT '').
saved, err := s.InsertEvent(EventLog{Source: "stale", Severity: "warn", Message: "x"})
if err != nil {
t.Fatalf("InsertEvent: %v", err)
}
if saved.WorkloadID != "" {
t.Fatalf("WorkloadID = %q, want empty", saved.WorkloadID)
}
rows, err := s.ListEvents(EventLogFilter{Source: "stale"})
if err != nil {
t.Fatalf("ListEvents: %v", err)
}
if len(rows) != 1 || rows[0].WorkloadID != "" {
t.Fatalf("expected one unscoped row, got %+v", rows)
}
}
func TestListEvents_FilterByWorkloadID(t *testing.T) {
s := newTestStore(t)
for _, e := range []EventLog{
{Source: "image", WorkloadID: "wl-1", Severity: "info", Message: "a"},
{Source: "image", WorkloadID: "wl-1", Severity: "error", Message: "b"},
{Source: "compose", WorkloadID: "wl-2", Severity: "info", Message: "c"},
{Source: "stale", WorkloadID: "", Severity: "warn", Message: "d"},
} {
if _, err := s.InsertEvent(e); err != nil {
t.Fatalf("InsertEvent %q: %v", e.Message, err)
}
}
// Filtering by wl-1 returns only its two rows.
rows, err := s.ListEvents(EventLogFilter{WorkloadID: "wl-1"})
if err != nil {
t.Fatalf("ListEvents wl-1: %v", err)
}
if len(rows) != 2 {
t.Fatalf("wl-1: got %d rows, want 2", len(rows))
}
for _, r := range rows {
if r.WorkloadID != "wl-1" {
t.Errorf("wl-1 filter leaked row with workload_id %q", r.WorkloadID)
}
}
// wl-2 returns exactly one row.
rows, err = s.ListEvents(EventLogFilter{WorkloadID: "wl-2"})
if err != nil {
t.Fatalf("ListEvents wl-2: %v", err)
}
if len(rows) != 1 || rows[0].Message != "c" {
t.Fatalf("wl-2: got %+v, want single row 'c'", rows)
}
// Combined workload + severity filter still narrows correctly.
rows, err = s.ListEvents(EventLogFilter{WorkloadID: "wl-1", Severity: "error"})
if err != nil {
t.Fatalf("ListEvents wl-1+error: %v", err)
}
if len(rows) != 1 || rows[0].Message != "b" {
t.Fatalf("wl-1+error: got %+v, want single row 'b'", rows)
}
// No filter returns all four rows (back-compat: unscoped query intact).
rows, err = s.ListEvents(EventLogFilter{})
if err != nil {
t.Fatalf("ListEvents all: %v", err)
}
if len(rows) != 4 {
t.Fatalf("unfiltered: got %d rows, want 4", len(rows))
}
}
+73 -71
View File
@@ -14,60 +14,60 @@ type Registry struct {
// Settings holds global application configuration (single-row pattern).
type Settings struct {
Domain string `json:"domain"`
ServerIP string `json:"server_ip"` // Docker host IP (for NPM remote forwarding)
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
BaseVolumePath string `json:"base_volume_path"`
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"`
NpmAccessListID int `json:"npm_access_list_id"`
ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"`
TraefikNetwork string `json:"traefik_network"`
TraefikAPIURL string `json:"traefik_api_url"`
ImagePruneThresholdMB int `json:"image_prune_threshold_mb"`
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
Domain string `json:"domain"`
ServerIP string `json:"server_ip"` // Docker host IP (for NPM remote forwarding)
PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"`
BaseVolumePath string `json:"base_volume_path"`
SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"`
NpmAccessListID int `json:"npm_access_list_id"`
ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"`
TraefikNetwork string `json:"traefik_network"`
TraefikAPIURL string `json:"traefik_api_url"`
ImagePruneThresholdMB int `json:"image_prune_threshold_mb"`
BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"`
// AutoBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB backup
// at the start of every project deploy. Independent of BackupEnabled
// (which governs the periodic auto-backup cron).
AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"`
AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"`
}
// ContainerStatsSample is one persisted sample of container resource usage.
// Cumulative counters (network, block I/O) require differencing two samples
// to get rates; CPU is already a percent-since-previous-sample value.
type ContainerStatsSample struct {
ContainerID string `json:"container_id"`
OwnerType string `json:"owner_type"` // "instance" or "site"
OwnerID string `json:"owner_id"`
TS int64 `json:"ts"` // Unix seconds UTC
ContainerID string `json:"container_id"`
OwnerType string `json:"owner_type"` // "instance" or "site"
OwnerID string `json:"owner_id"`
TS int64 `json:"ts"` // Unix seconds UTC
CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"`
MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"`
NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"`
}
// SystemStatsSample is one persisted host-level snapshot that aggregates
@@ -106,10 +106,12 @@ type DNSRecord struct {
// page. The legacy field names (ProjectID, ProjectName, StageID,
// StageName, InstanceID) are retained verbatim for the existing
// frontend contract — after the workload-first cutover they map to:
// ProjectID/Name → workload id / workload name
// StageID/Name → containers.stage_id / containers.role
// InstanceID → container row id
// Source → "instance" for image/compose, "static_site" for static
//
// ProjectID/Name → workload id / workload name
// StageID/Name → containers.stage_id / containers.role
// InstanceID → container row id
// Source → "instance" for image/compose, "static_site" for static
//
// Renaming would require a coordinated frontend change; deferred.
type ProxyRoute struct {
Source string `json:"source"`
@@ -190,12 +192,13 @@ func IsValidVolumeScope(s string) bool {
// EventLog represents a persistent event log entry.
type EventLog struct {
ID int64 `json:"id"`
Source string `json:"source"`
Severity string `json:"severity"` // info, warn, error
Message string `json:"message"`
Metadata string `json:"metadata"` // JSON-encoded structured data
CreatedAt string `json:"created_at"`
ID int64 `json:"id"`
Source string `json:"source"`
WorkloadID string `json:"workload_id"` // "" = unscoped (non-deploy events)
Severity string `json:"severity"` // info, warn, error
Message string `json:"message"`
Metadata string `json:"metadata"` // JSON-encoded structured data
CreatedAt string `json:"created_at"`
}
// EventTrigger is a filter+action rule evaluated against EventLog
@@ -245,12 +248,12 @@ const (
// for this workload).
type LogScanRule struct {
ID int64 `json:"id"`
WorkloadID string `json:"workload_id"` // "" = global
OverridesID int64 `json:"overrides_id"` // 0 = not an override
WorkloadID string `json:"workload_id"` // "" = global
OverridesID int64 `json:"overrides_id"` // 0 = not an override
Name string `json:"name"`
Pattern string `json:"pattern"` // regex, compiled at load
Severity string `json:"severity"` // info|warn|error
Streams string `json:"streams"` // all|stdout|stderr
Pattern string `json:"pattern"` // regex, compiled at load
Severity string `json:"severity"` // info|warn|error
Streams string `json:"streams"` // all|stdout|stderr
CooldownSeconds int `json:"cooldown_seconds"`
Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"`
@@ -308,13 +311,13 @@ type Workload struct {
Kind string `json:"kind"` // project | stack | site (legacy discriminator)
RefID string `json:"ref_id"`
Name string `json:"name"`
AppID string `json:"app_id"` // nullable; "" = unassigned (a.k.a. GroupID after rename)
SourceKind string `json:"source_kind"` // "" until plugin-mode populated
SourceConfig string `json:"source_config"` // JSON-encoded, decoded by the matching Source
AppID string `json:"app_id"` // nullable; "" = unassigned (a.k.a. GroupID after rename)
SourceKind string `json:"source_kind"` // "" until plugin-mode populated
SourceConfig string `json:"source_config"` // JSON-encoded, decoded by the matching Source
TriggerKind string `json:"trigger_kind"`
TriggerConfig string `json:"trigger_config"` // JSON-encoded, decoded by the matching Trigger
PublicFaces string `json:"public_faces"` // JSON-encoded []PublicFace
ParentWorkloadID string `json:"parent_workload_id"` // "" = root; non-empty = stage chain
TriggerConfig string `json:"trigger_config"` // JSON-encoded, decoded by the matching Trigger
PublicFaces string `json:"public_faces"` // JSON-encoded []PublicFace
ParentWorkloadID string `json:"parent_workload_id"` // "" = root; non-empty = stage chain
NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // never serialized
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
@@ -393,11 +396,11 @@ type Container struct {
// which workloads to fire.
type Trigger struct {
ID string `json:"id"`
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"`
// LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched
// this trigger. Empty for never-fired or non-schedule triggers. The
@@ -433,4 +436,3 @@ type App struct {
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
+7
View File
@@ -178,6 +178,12 @@ func (s *Store) runMigrations() error {
// Empty string = never fired. Pre-trigger-split DBs land the column
// here so the scheduler can read/write it on first boot.
`ALTER TABLE triggers ADD COLUMN last_fired_at TEXT NOT NULL DEFAULT ''`,
// Per-app deploy/activity timeline: scope each event_log row to the
// workload that produced it so the dashboard can query a workload's
// deploy history. Empty string = unscoped (the existing non-deploy
// loggers don't set it). Additive ADD COLUMN — the loop below
// tolerates the "duplicate column" error on fully-migrated DBs.
`ALTER TABLE event_log ADD COLUMN workload_id TEXT NOT NULL DEFAULT ''`,
// Hard cutover: drop every legacy table. Idempotent — DROP TABLE
// IF EXISTS is a no-op once the table is gone. Operators upgrading
// from a pre-cutover build will lose any project / stack / static
@@ -432,6 +438,7 @@ func (s *Store) runMigrations() error {
`CREATE INDEX IF NOT EXISTS idx_event_log_severity ON event_log(severity)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_source ON event_log(source)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_created_at ON event_log(created_at)`,
`CREATE INDEX IF NOT EXISTS idx_event_log_workload ON event_log(workload_id, created_at)`,
`CREATE INDEX IF NOT EXISTS idx_dns_records_consumer ON dns_records(consumer_type, consumer_id)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_owner_ts ON container_stats_samples(owner_type, owner_id, ts)`,
`CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,