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)