93b6911b34
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.
196 lines
5.4 KiB
Go
196 lines
5.4 KiB
Go
package store
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// 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.
|
|
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.
|
|
type EventLogStats struct {
|
|
Info int `json:"info"`
|
|
Warn int `json:"warn"`
|
|
Error int `json:"error"`
|
|
Total int `json:"total"`
|
|
}
|
|
|
|
// InsertEvent inserts a new event log entry.
|
|
func (s *Store) InsertEvent(evt EventLog) (EventLog, error) {
|
|
evt.CreatedAt = Now()
|
|
if evt.Metadata == "" {
|
|
evt.Metadata = "{}"
|
|
}
|
|
|
|
result, err := s.db.Exec(
|
|
`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)
|
|
}
|
|
|
|
id, err := result.LastInsertId()
|
|
if err != nil {
|
|
return EventLog{}, fmt.Errorf("get event id: %w", err)
|
|
}
|
|
evt.ID = id
|
|
|
|
return evt, nil
|
|
}
|
|
|
|
// ListEvents returns event log entries matching the given filter.
|
|
func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
|
|
var conditions []string
|
|
var args []any
|
|
|
|
if filter.Severity != "" {
|
|
parts := strings.Split(filter.Severity, ",")
|
|
if len(parts) == 1 {
|
|
conditions = append(conditions, "severity = ?")
|
|
args = append(args, filter.Severity)
|
|
} else {
|
|
placeholders := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(p))
|
|
}
|
|
conditions = append(conditions, "severity IN ("+strings.Join(placeholders, ",")+")")
|
|
}
|
|
}
|
|
if filter.Source != "" {
|
|
parts := strings.Split(filter.Source, ",")
|
|
if len(parts) == 1 {
|
|
conditions = append(conditions, "source = ?")
|
|
args = append(args, filter.Source)
|
|
} else {
|
|
placeholders := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
placeholders[i] = "?"
|
|
args = append(args, strings.TrimSpace(p))
|
|
}
|
|
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)
|
|
}
|
|
if filter.Until != "" {
|
|
conditions = append(conditions, "created_at <= ?")
|
|
args = append(args, filter.Until)
|
|
}
|
|
|
|
query := "SELECT id, source, workload_id, severity, message, metadata, created_at FROM event_log"
|
|
if len(conditions) > 0 {
|
|
query += " WHERE " + strings.Join(conditions, " AND ")
|
|
}
|
|
query += " ORDER BY created_at DESC"
|
|
|
|
limit := filter.Limit
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 500 {
|
|
limit = 500
|
|
}
|
|
query += fmt.Sprintf(" LIMIT %d OFFSET %d", limit, filter.Offset)
|
|
|
|
rows, err := s.db.Query(query, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query events: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
events := []EventLog{}
|
|
for rows.Next() {
|
|
var evt EventLog
|
|
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)
|
|
}
|
|
return events, rows.Err()
|
|
}
|
|
|
|
// GetEventStats returns counts of event log entries grouped by severity.
|
|
func (s *Store) GetEventStats() (EventLogStats, error) {
|
|
rows, err := s.db.Query(
|
|
`SELECT severity, COUNT(*) FROM event_log GROUP BY severity`,
|
|
)
|
|
if err != nil {
|
|
return EventLogStats{}, fmt.Errorf("query event stats: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var stats EventLogStats
|
|
for rows.Next() {
|
|
var severity string
|
|
var count int
|
|
if err := rows.Scan(&severity, &count); err != nil {
|
|
return EventLogStats{}, fmt.Errorf("scan event stats: %w", err)
|
|
}
|
|
switch severity {
|
|
case "info":
|
|
stats.Info = count
|
|
case "warn":
|
|
stats.Warn = count
|
|
case "error":
|
|
stats.Error = count
|
|
}
|
|
stats.Total += count
|
|
}
|
|
return stats, rows.Err()
|
|
}
|
|
|
|
// PruneEvents deletes event log entries older than the given number of days.
|
|
// DeleteEvent removes a single event log entry by ID.
|
|
func (s *Store) DeleteEvent(id int64) error {
|
|
result, err := s.db.Exec(`DELETE FROM event_log WHERE id = ?`, id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete event: %w", err)
|
|
}
|
|
n, _ := result.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("event %d: %w", id, ErrNotFound)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ClearAllEvents removes all event log entries.
|
|
func (s *Store) ClearAllEvents() (int64, error) {
|
|
result, err := s.db.Exec(`DELETE FROM event_log`)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("clear events: %w", err)
|
|
}
|
|
return result.RowsAffected()
|
|
}
|
|
|
|
func (s *Store) PruneEvents(olderThanDays int) (int64, error) {
|
|
if olderThanDays < 1 {
|
|
return 0, fmt.Errorf("prune events: olderThanDays must be >= 1, got %d", olderThanDays)
|
|
}
|
|
result, err := s.db.Exec(
|
|
`DELETE FROM event_log WHERE created_at < datetime('now', ?)`,
|
|
fmt.Sprintf("-%d days", olderThanDays),
|
|
)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("prune events: %w", err)
|
|
}
|
|
return result.RowsAffected()
|
|
}
|