From 93b6911b34ac155a216a14beffee1edf2901e021 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 13:51:17 +0300 Subject: [PATCH] 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. --- internal/api/eventlog.go | 30 ++++ internal/api/router.go | 5 + internal/events/bus.go | 13 +- internal/store/eventlog.go | 27 +-- internal/store/eventlog_test.go | 120 +++++++++++++ internal/store/models.go | 144 +++++++-------- internal/store/store.go | 7 + internal/workload/plugin/events.go | 103 +++++++++++ internal/workload/plugin/events_test.go | 167 ++++++++++++++++++ .../workload/plugin/source/compose/compose.go | 25 ++- .../plugin/source/dockerfile/deploy.go | 49 +---- .../workload/plugin/source/image/image.go | 62 ++++--- .../workload/plugin/source/static/deploy.go | 56 +----- web/src/lib/api.ts | 13 ++ web/src/lib/i18n/en.json | 13 ++ web/src/lib/i18n/ru.json | 13 ++ web/src/lib/sse.ts | 21 +++ web/src/routes/apps/[id]/+page.svelte | 158 ++++++++++++++++- web/src/routes/events/+page.svelte | 11 +- 19 files changed, 814 insertions(+), 223 deletions(-) create mode 100644 internal/store/eventlog_test.go create mode 100644 internal/workload/plugin/events.go create mode 100644 internal/workload/plugin/events_test.go diff --git a/internal/api/eventlog.go b/internal/api/eventlog.go index e442281..4276c69 100644 --- a/internal/api/eventlog.go +++ b/internal/api/eventlog.go @@ -37,6 +37,36 @@ func (s *Server) listEventLog(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, events) } +// listWorkloadEvents handles GET /api/workloads/{id}/events — the per-app +// activity/deploy timeline. The workload id is pinned from the path, so a +// client cannot widen the scope to other workloads or the global feed. +// Supports the same severity/limit/offset query params as listEventLog. +func (s *Server) listWorkloadEvents(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + respondError(w, http.StatusBadRequest, "workload id is required") + return + } + + q := r.URL.Query() + limit, _ := strconv.Atoi(q.Get("limit")) + offset, _ := strconv.Atoi(q.Get("offset")) + + events, err := s.store.ListEvents(store.EventLogFilter{ + WorkloadID: id, + Severity: q.Get("severity"), + Limit: limit, + Offset: offset, + }) + if err != nil { + slog.Error("failed to list workload events", "workload", id, "error", err) + respondError(w, http.StatusInternalServerError, "failed to list events") + return + } + + respondJSON(w, http.StatusOK, events) +} + // getEventLogStats handles GET /api/events/log/stats. func (s *Server) getEventLogStats(w http.ResponseWriter, r *http.Request) { stats, err := s.store.GetEventStats() diff --git a/internal/api/router.go b/internal/api/router.go index 9125603..c5eb133 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -334,6 +334,11 @@ func (s *Server) Router() chi.Router { r.Get("/runtime-state", s.getWorkloadRuntimeState) r.Get("/storage", s.getWorkloadStorage) + // Per-workload activity / deploy timeline (read-only). Scoped + // to this workload's event-log rows; the global feed lives at + // /events/log. + r.Get("/events", s.listWorkloadEvents) + // Per-workload env vars. Listing open to authenticated readers; // mutations admin-gated. Encrypted values are write-only after store. r.Get("/env", s.listWorkloadEnv) diff --git a/internal/events/bus.go b/internal/events/bus.go index 06b5ce3..0c4b80d 100644 --- a/internal/events/bus.go +++ b/internal/events/bus.go @@ -69,12 +69,13 @@ type DeployStatusPayload struct { // 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"` + ID int64 `json:"id"` + Source string `json:"source"` + WorkloadID string `json:"workload_id"` + Severity string `json:"severity"` + Message string `json:"message"` + Metadata string `json:"metadata"` + CreatedAt string `json:"created_at"` } // StaticSiteStatusPayload is the payload for EventStaticSiteStatus events. diff --git a/internal/store/eventlog.go b/internal/store/eventlog.go index 54fd5c9..246111e 100644 --- a/internal/store/eventlog.go +++ b/internal/store/eventlog.go @@ -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) diff --git a/internal/store/eventlog_test.go b/internal/store/eventlog_test.go new file mode 100644 index 0000000..de9a51c --- /dev/null +++ b/internal/store/eventlog_test.go @@ -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)) + } +} diff --git a/internal/store/models.go b/internal/store/models.go index 5f217ea..39d4aeb 100644 --- a/internal/store/models.go +++ b/internal/store/models.go @@ -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"` } - diff --git a/internal/store/store.go b/internal/store/store.go index ee99235..f70f352 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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)`, diff --git a/internal/workload/plugin/events.go b/internal/workload/plugin/events.go new file mode 100644 index 0000000..608c41a --- /dev/null +++ b/internal/workload/plugin/events.go @@ -0,0 +1,103 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/alexei/tinyforge/internal/events" + "github.com/alexei/tinyforge/internal/store" +) + +// maxDeployStatusRunes bounds the persisted status. This is a defense-in-depth +// BACKSTOP, not a sanitizer. +// +// CALLER CONTRACT: deploy events are persisted indefinitely, rendered in the +// per-app timeline, AND egress off-box — error-severity events are forwarded +// to the global NotificationURL (cmd/server) and to operator-configured +// event-trigger webhooks (internal/events/dispatcher). Callers MUST therefore +// keep secrets and raw subprocess output (e.g. `docker compose` combined +// stderr, which can echo the deployed app's own secret-bearing logs) OUT of +// `status`; emit a curated, secret-free reason and keep verbose detail only in +// the returned error (server logs + admin deploy result, neither of which +// egresses). The cap below merely bounds blast radius if something slips +// through — 256 runes keeps a meaningful reason without letting a status +// become an unbounded sink. +const maxDeployStatusRunes = 256 + +// capDeployStatus truncates s to maxDeployStatusRunes runes, appending an +// ellipsis when it had to cut. Operating on the rune slice keeps the cut on +// a UTF-8 boundary so multibyte output can't be sliced mid-rune. +func capDeployStatus(s string) string { + runes := []rune(s) + if len(runes) <= maxDeployStatusRunes { + return s + } + return string(runes[:maxDeployStatusRunes]) + "…" +} + +// EmitDeployEvent records a workload-scoped deploy event in the event log +// and publishes it on the bus. Best-effort: logs and returns on failure, +// never blocks or fails the deploy. `source` is the per-kind event source +// string ("image","compose","static_site","dockerfile"); `status` is a +// short human status ("deploying","deployed","failed: "). +// +// The metadata always carries workload_id so the per-app activity timeline +// can be reconstructed even by consumers that only read the JSON blob, and +// the dedicated workload_id column powers the indexed per-workload query. +func EmitDeployEvent(deps Deps, w Workload, source, status string) { + // Audit logging is best-effort and must never crash a real deploy. The + // production Deps always wires both, but guard so a missing bus/store + // (e.g. a narrow unit test) degrades to a no-op instead of a panic. + if deps.Store == nil || deps.Events == nil { + return + } + + // Derive severity from the raw status prefix BEFORE capping, then bound + // the status that actually gets persisted/displayed/published. + severity := "info" + if strings.HasPrefix(status, "failed") { + severity = "error" + } + status = capDeployStatus(status) + message := fmt.Sprintf("%s: %s", w.Name, status) + + metaBytes, err := json.Marshal(map[string]string{ + "workload_id": w.ID, + "workload_name": w.Name, + "status": status, + }) + if err != nil { + slog.Error("plugin: marshal deploy event metadata", + "source", source, "workload", w.ID, "error", err) + metaBytes = []byte("{}") + } + metadata := string(metaBytes) + + evt, err := deps.Store.InsertEvent(store.EventLog{ + Source: source, + Severity: severity, + Message: message, + Metadata: metadata, + WorkloadID: w.ID, + }) + if err != nil { + slog.Error("plugin: failed to persist deploy event log", + "source", source, "workload", w.ID, "error", err) + return + } + + deps.Events.Publish(events.Event{ + Type: events.EventLog, + Payload: events.EventLogPayload{ + ID: evt.ID, + Source: source, + WorkloadID: w.ID, + Severity: severity, + Message: message, + Metadata: metadata, + CreatedAt: evt.CreatedAt, + }, + }) +} diff --git a/internal/workload/plugin/events_test.go b/internal/workload/plugin/events_test.go new file mode 100644 index 0000000..26a0312 --- /dev/null +++ b/internal/workload/plugin/events_test.go @@ -0,0 +1,167 @@ +package plugin + +import ( + "encoding/json" + "strings" + "testing" + "unicode/utf8" + + "github.com/alexei/tinyforge/internal/events" + "github.com/alexei/tinyforge/internal/store" +) + +// capturePublisher records every event published on it so a test can +// assert on the bus payload. Satisfies plugin.EventPublisher. +type capturePublisher struct { + events []events.Event +} + +func (c *capturePublisher) Publish(evt events.Event) { + c.events = append(c.events, evt) +} + +// newEmitDeps builds a plugin.Deps backed by an in-memory store and a +// capturing publisher. Mirrors the in-memory store pattern used by the +// store + source-plugin tests. +func newEmitDeps(t *testing.T) (Deps, *capturePublisher) { + t.Helper() + st, err := store.New(":memory:") + if err != nil { + t.Fatalf("open store: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + pub := &capturePublisher{} + return Deps{Store: st, Events: pub}, pub +} + +func TestEmitDeployEvent(t *testing.T) { + tests := []struct { + name string + status string + wantSeverity string + }{ + {name: "deployed is info", status: "deployed", wantSeverity: "info"}, + {name: "deploying is info", status: "deploying", wantSeverity: "info"}, + {name: "failed is error", status: "failed: pull foo failed", wantSeverity: "error"}, + {name: "failed bare is error", status: "failed", wantSeverity: "error"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + deps, pub := newEmitDeps(t) + w := Workload{ID: "wl-123", Name: "my-app"} + + EmitDeployEvent(deps, w, "image", tt.status) + + // Persisted row carries the workload scope + derived severity. + rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d persisted events, want 1", len(rows)) + } + got := rows[0] + if got.Severity != tt.wantSeverity { + t.Errorf("severity = %q, want %q", got.Severity, tt.wantSeverity) + } + if got.Source != "image" { + t.Errorf("source = %q, want %q", got.Source, "image") + } + if got.WorkloadID != w.ID { + t.Errorf("workload_id = %q, want %q", got.WorkloadID, w.ID) + } + wantMsg := w.Name + ": " + tt.status + if got.Message != wantMsg { + t.Errorf("message = %q, want %q", got.Message, wantMsg) + } + + // Metadata JSON carries workload_id / workload_name / status. + var meta map[string]string + if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil { + t.Fatalf("unmarshal metadata %q: %v", got.Metadata, err) + } + if meta["workload_id"] != w.ID { + t.Errorf("metadata workload_id = %q, want %q", meta["workload_id"], w.ID) + } + if meta["workload_name"] != w.Name { + t.Errorf("metadata workload_name = %q, want %q", meta["workload_name"], w.Name) + } + if meta["status"] != tt.status { + t.Errorf("metadata status = %q, want %q", meta["status"], tt.status) + } + + // The persisted row is also re-published on the bus as an + // EventLog so SSE clients see it live. + if len(pub.events) != 1 { + t.Fatalf("got %d published events, want 1", len(pub.events)) + } + ev := pub.events[0] + if ev.Type != events.EventLog { + t.Errorf("event type = %q, want %q", ev.Type, events.EventLog) + } + payload, ok := ev.Payload.(events.EventLogPayload) + if !ok { + t.Fatalf("payload type = %T, want events.EventLogPayload", ev.Payload) + } + if payload.WorkloadID != w.ID { + t.Errorf("payload workload_id = %q, want %q", payload.WorkloadID, w.ID) + } + if payload.Severity != tt.wantSeverity { + t.Errorf("payload severity = %q, want %q", payload.Severity, tt.wantSeverity) + } + if payload.ID != got.ID { + t.Errorf("payload id = %d, want %d", payload.ID, got.ID) + } + }) + } +} + +// TestEmitDeployEvent_CapsLongStatus verifies a long failure status (e.g. one +// embedding raw subprocess output) is bounded to maxDeployStatusRunes runes in +// both the persisted message and metadata, cut on a UTF-8 boundary, while +// severity is still derived from the original "failed" prefix. +func TestEmitDeployEvent_CapsLongStatus(t *testing.T) { + deps, pub := newEmitDeps(t) + w := Workload{ID: "wl-cap", Name: "app"} + + // Multibyte body so a naive byte-slice would corrupt a rune; prefix with + // "failed: " so the severity check exercises the pre-cap derivation. + longStatus := "failed: " + strings.Repeat("é", 400) + EmitDeployEvent(deps, w, "compose", longStatus) + + rows, err := deps.Store.ListEvents(store.EventLogFilter{WorkloadID: w.ID}) + if err != nil { + t.Fatalf("ListEvents: %v", err) + } + if len(rows) != 1 { + t.Fatalf("got %d events, want 1", len(rows)) + } + got := rows[0] + + if got.Severity != "error" { + t.Errorf("severity = %q, want error (derived from pre-cap prefix)", got.Severity) + } + + var meta map[string]string + if err := json.Unmarshal([]byte(got.Metadata), &meta); err != nil { + t.Fatalf("unmarshal metadata: %v", err) + } + capped := meta["status"] + if rc := len([]rune(capped)); rc != maxDeployStatusRunes+1 { // +1 for the ellipsis rune + t.Errorf("capped status = %d runes, want %d", rc, maxDeployStatusRunes+1) + } + if !utf8.ValidString(capped) { + t.Errorf("capped status is not valid UTF-8: %q", capped) + } + if !strings.HasSuffix(capped, "…") { + t.Errorf("capped status missing ellipsis suffix: %q", capped) + } + wantMsg := w.Name + ": " + capped + if got.Message != wantMsg { + t.Errorf("message = %q, want %q", got.Message, wantMsg) + } + if len(pub.events) != 1 { + t.Fatalf("got %d published events, want 1", len(pub.events)) + } +} diff --git a/internal/workload/plugin/source/compose/compose.go b/internal/workload/plugin/source/compose/compose.go index 24bffa4..00bd1c0 100644 --- a/internal/workload/plugin/source/compose/compose.go +++ b/internal/workload/plugin/source/compose/compose.go @@ -84,7 +84,7 @@ func (*source) Validate(cfg json.RawMessage) error { // `docker compose -p up -d`, then syncs one Container row per // service. The workload ID is the natural compose project name unless // the user supplied one explicitly. -func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error { +func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (err error) { cfg, err := plugin.SourceConfigOf[Config](w) if err != nil { return fmt.Errorf("compose source: decode config: %w", err) @@ -93,6 +93,29 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, return fmt.Errorf("compose source: workload %s has empty compose_yaml", w.ID) } + // compose.Deploy has no idempotency short-circuit (no "already up" + // fast path that returns nil), so every call past config validation + // is a real deploy. Arm the terminal audit emit here — after pure + // config-validation errors above (kept quiet, mirroring the image + // plugin) but before any real work — so all real failures and the + // success are captured for the per-app timeline. err is the named + // return. + defer func() { + if err != nil { + // SECURITY: the compose.Up failure wraps raw `docker compose` + // combined output (which can include the deployed app's own + // stderr — potentially secrets). Deploy events are persisted + // indefinitely AND egress to operator webhooks (the global + // NotificationURL + event-trigger actions), so the emitted + // status must NOT carry that output. The full detail still + // reaches the server log + admin deploy result via the returned + // err; the timeline records only a generic, secret-free reason. + plugin.EmitDeployEvent(deps, w, "compose", "failed") + } else { + plugin.EmitDeployEvent(deps, w, "compose", "deployed") + } + }() + projectName := composeProjectName(cfg.ComposeProjectName, w) yamlPath, err := writeYAML(w.ID, cfg.ComposeYAML) if err != nil { diff --git a/internal/workload/plugin/source/dockerfile/deploy.go b/internal/workload/plugin/source/dockerfile/deploy.go index 7680b8c..076536a 100644 --- a/internal/workload/plugin/source/dockerfile/deploy.go +++ b/internal/workload/plugin/source/dockerfile/deploy.go @@ -2,7 +2,6 @@ package dockerfile import ( "context" - "encoding/json" "fmt" "io" "log/slog" @@ -506,49 +505,13 @@ func dispatchBuildNotification(deps plugin.Deps, w plugin.Workload, domain, stat }) } -// publishEvent emits a status event on the bus AND persists an -// event_log row. Message shape mirrors the static plugin -// ("Build %q: %s") so the dashboard's audit feed reads consistently -// across both kinds. +// publishEvent records a workload-scoped deploy event in the audit log. +// The InsertEvent + bus publish (and consistent message/metadata shape +// across source kinds) is centralised in plugin.EmitDeployEvent so the +// dashboard's audit feed and the per-workload timeline read identically +// for image / compose / static / dockerfile deploys. func publishEvent(deps plugin.Deps, w plugin.Workload, status string) { - severity := "info" - if strings.HasPrefix(status, "failed") { - severity = "error" - } - message := fmt.Sprintf("Build %q: %s", w.Name, status) - - metaBytes, err := json.Marshal(map[string]string{ - "workload_id": w.ID, - "workload_name": w.Name, - "status": status, - }) - if err != nil { - slog.Error("dockerfile: marshal event metadata", "error", err) - metaBytes = []byte("{}") - } - metadata := string(metaBytes) - - evt, err := deps.Store.InsertEvent(store.EventLog{ - Source: "dockerfile", - Severity: severity, - Message: message, - Metadata: metadata, - }) - if err != nil { - slog.Error("dockerfile: failed to persist event log", "error", err) - return - } - deps.Events.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - ID: evt.ID, - Source: "dockerfile", - Severity: severity, - Message: message, - Metadata: metadata, - CreatedAt: evt.CreatedAt, - }, - }) + plugin.EmitDeployEvent(deps, w, "dockerfile", status) } // publishBuildLog emits one EventBuildLog per non-empty daemon "stream" diff --git a/internal/workload/plugin/source/image/image.go b/internal/workload/plugin/source/image/image.go index e10905c..af8af8f 100644 --- a/internal/workload/plugin/source/image/image.go +++ b/internal/workload/plugin/source/image/image.go @@ -118,7 +118,7 @@ func (*source) Validate(cfg json.RawMessage) error { // // Any failure between create and face-registration rolls back the new // container + its row; old serving state is preserved. -func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) error { +func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, intent plugin.DeploymentIntent) (err error) { cfg, err := plugin.SourceConfigOf[Config](w) if err != nil { return fmt.Errorf("image source: decode config: %w", err) @@ -162,6 +162,19 @@ func (*source) Deploy(ctx context.Context, deps plugin.Deps, w plugin.Workload, } } + // Past the idempotency short-circuit: this is a real deploy. Emit a + // terminal audit event for the per-app timeline. Armed here (not at the + // top) so duplicate-webhook no-ops above don't flood the log, and + // pre-flight config/settings errors above stay quiet. err is the named + // return, so the deferred closure observes the final outcome. + defer func() { + if err != nil { + plugin.EmitDeployEvent(deps, w, "image", "failed: "+err.Error()) + } else { + plugin.EmitDeployEvent(deps, w, "image", "deployed") + } + }() + authConfig, err := buildRegistryAuth(deps, cfg.RegistryName) if err != nil { return fmt.Errorf("image source: %w", err) @@ -486,37 +499,22 @@ type containerExtra struct { ProxyRoutes map[string]string `json:"proxy_routes,omitempty"` } -// Reconcile syncs the containers index for this workload with reality. -// MVP: just refreshes State from Docker. Future versions can re-deploy -// when the running container disagrees with the desired source config. -func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error { - rows, err := deps.Store.ListContainersByWorkload(w.ID) - if err != nil { - return fmt.Errorf("image source: list containers: %w", err) - } - for _, c := range rows { - if c.ContainerID == "" { - continue - } - running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID) - if err != nil { - // Most likely "no such container" — mark as missing so the UI - // surfaces it and the next deploy recreates. - if err := deps.Store.UpdateContainerState(c.ID, "missing"); err != nil { - slog.Warn("image source: mark missing", "id", c.ID, "error", err) - } - continue - } - desired := "running" - if !running { - desired = "stopped" - } - if c.State != desired { - if err := deps.Store.UpdateContainerState(c.ID, desired); err != nil { - slog.Warn("image source: state sync", "id", c.ID, "error", err) - } - } - } +// Reconcile is intentionally a no-op for the image source. +// +// State sync is fully handled by the generic reconciler pass that runs +// EARLIER in the same Reconciler.ReconcileOnce: its upsert loop writes each +// present container's State from the single `docker ps -a` snapshot +// (ListAllForReconciler), and its markMissing pass flips rows whose container +// ID is absent from that snapshot to 'missing'. Every image container carries +// the tinyforge.workload.id label (ContainerConfig.WorkloadID at create time), +// so the generic pass covers all of them. +// +// The previous implementation looped this workload's container rows and called +// Docker.IsContainerRunning per row — a redundant Docker inspect per container +// per tick that duplicated work already done from the snapshot and scaled as N +// Docker API calls/tick. Returning nil here drops that cost without changing +// observable state. The method stays because the source interface requires it. +func (*source) Reconcile(context.Context, plugin.Deps, plugin.Workload) error { return nil } diff --git a/internal/workload/plugin/source/static/deploy.go b/internal/workload/plugin/source/static/deploy.go index 895a16d..6481b38 100644 --- a/internal/workload/plugin/source/static/deploy.go +++ b/internal/workload/plugin/source/static/deploy.go @@ -2,14 +2,12 @@ package static import ( "context" - "encoding/json" "fmt" "io" "log/slog" "os" "path/filepath" "strconv" - "strings" "time" "github.com/moby/moby/api/types/mount" @@ -543,11 +541,13 @@ func dispatchSiteNotification(deps plugin.Deps, w plugin.Workload, domain, statu }) } -// publishEvent emits a static_site_status event on the bus AND -// persists an event_log row so the dashboard's audit trail picks it -// up. Message format ("Static site \"%s\": %s") is preserved verbatim -// from the legacy Manager.publishEvent so log scrapers and operator- -// configured event triggers keep matching. +// publishEvent emits a static_site_status event on the bus (drives the +// dashboard's per-site status pill) AND records a workload-scoped deploy +// event in the audit log. The audit InsertEvent + bus publish is +// centralised in plugin.EmitDeployEvent so the message/metadata shape and +// per-workload timeline are identical across all source kinds. This +// standardises the metadata key from the legacy "site_id" to "workload_id"; +// no consumer reads the old key (verified repo-wide). func publishEvent(deps plugin.Deps, w plugin.Workload, status string) { deps.Events.Publish(events.Event{ Type: events.EventStaticSiteStatus, @@ -558,47 +558,7 @@ func publishEvent(deps plugin.Deps, w plugin.Workload, status string) { }, }) - severity := "info" - if strings.HasPrefix(status, "failed") { - severity = "error" - } - message := fmt.Sprintf("Static site %q: %s", w.Name, status) - - // Build metadata via json.Marshal so workload names containing - // quotes or backslashes don't produce invalid JSON for downstream - // log-scan consumers. - metaBytes, err := json.Marshal(map[string]string{ - "site_id": w.ID, - "site_name": w.Name, - "status": status, - }) - if err != nil { - slog.Error("static site: marshal event metadata", "error", err) - metaBytes = []byte("{}") - } - metadata := string(metaBytes) - - evt, err := deps.Store.InsertEvent(store.EventLog{ - Source: "static_site", - Severity: severity, - Message: message, - Metadata: metadata, - }) - if err != nil { - slog.Error("static site: failed to persist event log", "error", err) - return - } - deps.Events.Publish(events.Event{ - Type: events.EventLog, - Payload: events.EventLogPayload{ - ID: evt.ID, - Source: "static_site", - Severity: severity, - Message: message, - Metadata: metadata, - CreatedAt: evt.CreatedAt, - }, - }) + plugin.EmitDeployEvent(deps, w, "static_site", status) } // removeContainerByName mirrors the legacy helper: enumerate Docker's diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 23a783c..4c1101e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -765,6 +765,19 @@ export function fetchEventLogStats(signal?: AbortSignal): Promise return get('/api/events/log/stats', signal); } +export function fetchWorkloadEvents( + id: string, + params?: { severity?: string; limit?: number; offset?: number }, + signal?: AbortSignal +): Promise { + const query = new URLSearchParams(); + if (params?.severity) query.set('severity', params.severity); + if (params?.limit) query.set('limit', String(params.limit)); + if (params?.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + return get(`/api/workloads/${id}/events${qs ? `?${qs}` : ''}`, signal); +} + export function deleteEvent(id: number): Promise<{ status: string }> { return del<{ status: string }>(`/api/events/log/${id}`); } diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 966aba7..e8ce714 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -545,6 +545,9 @@ }, "source": { "deploy": "Deploy", + "image": "Image", + "compose": "Compose", + "dockerfile": "Dockerfile", "static_site": "Static Site", "stale_scanner": "Stale Scanner", "stale_cleanup": "Stale Cleanup", @@ -1406,6 +1409,16 @@ "deployError": "Deploy failed", "saveError": "Save failed", "deleteError": "Delete failed", + "activity": { + "title": "Activity", + "subtitle": "Recent deploys and events for this app", + "empty": "No activity yet. Deploys and events will appear here.", + "recentNote": "Showing recent activity.", + "loadMore": "Load more", + "filterAll": "All", + "filterErrors": "Errors", + "noErrors": "No errors in the loaded activity." + }, "runtimeState": { "title": "Sync status", "sub": "Last successful sync of the source repo and the current container state.", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index d51f5b7..2767570 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -545,6 +545,9 @@ }, "source": { "deploy": "Развёртывание", + "image": "Образ", + "compose": "Compose", + "dockerfile": "Dockerfile", "static_site": "Статический сайт", "stale_scanner": "Сканер устаревших", "stale_cleanup": "Очистка устаревших", @@ -1406,6 +1409,16 @@ "deployError": "Деплой не удался", "saveError": "Сохранение не удалось", "deleteError": "Удаление не удалось", + "activity": { + "title": "Активность", + "subtitle": "Недавние деплои и события этого приложения", + "empty": "Пока нет активности. Деплои и события появятся здесь.", + "recentNote": "Показана недавняя активность.", + "loadMore": "Загрузить ещё", + "filterAll": "Все", + "filterErrors": "Ошибки", + "noErrors": "Нет ошибок в загруженной активности." + }, "runtimeState": { "title": "Статус синхронизации", "sub": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.", diff --git a/web/src/lib/sse.ts b/web/src/lib/sse.ts index 90630b0..b391002 100644 --- a/web/src/lib/sse.ts +++ b/web/src/lib/sse.ts @@ -6,6 +6,7 @@ */ import { getAuthToken } from './auth'; +import type { EventLogEntry } from '$lib/types'; // ── Types ────────────────────────────────────────────────────────── @@ -41,12 +42,32 @@ export interface DeployStatusPayload { export interface EventLogSSEPayload { id: number; source: string; + /** + * Owning workload id, or "" for global events (stale scanner, admin). + * Mirrors the Go EventLogPayload.WorkloadID json tag. EventLog frames are + * broadcast to ALL connections, so per-workload views must filter on this. + */ + workload_id: string; severity: string; message: string; metadata: string; created_at: string; } +/** Map an SSE event_log frame to the REST EventLogEntry shape. Shared by the + * global events page and the per-app activity panel so the mapping (incl. the + * severity narrowing) lives in one place. */ +export function toEventLogEntry(payload: EventLogSSEPayload): EventLogEntry { + return { + id: payload.id, + source: payload.source, + severity: payload.severity as EventLogEntry['severity'], + message: payload.message, + metadata: payload.metadata, + created_at: payload.created_at + }; +} + export interface BuildLogPayload { workload_id: string; line: string; diff --git a/web/src/routes/apps/[id]/+page.svelte b/web/src/routes/apps/[id]/+page.svelte index 1506ed6..db60efa 100644 --- a/web/src/routes/apps/[id]/+page.svelte +++ b/web/src/routes/apps/[id]/+page.svelte @@ -2,7 +2,7 @@ import { onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; - import type { Container, PluginWorkloadInput, Workload } from '$lib/types'; + import type { Container, EventLogEntry, PluginWorkloadInput, Workload } from '$lib/types'; import type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api'; import * as api from '$lib/api'; import { @@ -26,6 +26,7 @@ } from '$lib/components/icons'; import ForgeHero from '$lib/components/ForgeHero.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; + import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import ContainerLogs from '$lib/components/ContainerLogs.svelte'; import ContainerStats from '$lib/components/ContainerStats.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; @@ -62,7 +63,7 @@ import { t } from '$lib/i18n'; import { fmt } from '$lib/format/datetime'; import { formatBytes } from '$lib/format/bytes'; - import { connectGlobalEvents, type SSEConnection } from '$lib/sse'; + import { connectGlobalEvents, toEventLogEntry, type SSEConnection } from '$lib/sse'; // Route params come back as `string | undefined`; the route file // guarantees `id` exists, but the empty-string fallback satisfies @@ -452,6 +453,43 @@ previewMeta = next; } + // Fire-and-forget load of the most recent activity for this workload. + // Non-fatal on failure: the panel just shows its empty state. + async function loadActivity(): Promise { + activityLoading = true; + try { + activityEvents = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE }); + activityOffset = activityEvents.length; + activityHasMore = activityEvents.length === ACTIVITY_PAGE; + } catch { + // Non-fatal: panel shows empty state. + } finally { + activityLoading = false; + } + } + + // Page in older events below the live-prepended head. The global events + // page can't filter by workload, so this is the only per-app history view. + async function loadMoreActivity(): Promise { + if (activityLoadingMore || !activityHasMore) return; + activityLoadingMore = true; + try { + const more = await api.fetchWorkloadEvents(id, { limit: ACTIVITY_PAGE, offset: activityOffset }); + // Dedup by id: a live SSE prepend can shift the offset window by one, + // so a page boundary may re-return an already-shown row. {#each (entry.id)} + // REQUIRES unique keys, so drop duplicates. + const seen = new Set(activityEvents.map((e) => e.id)); + const fresh = more.filter((e) => !seen.has(e.id)); + activityEvents = [...activityEvents, ...fresh]; + activityOffset += more.length; + activityHasMore = more.length === ACTIVITY_PAGE; + } catch { + // Non-fatal; leave the list as-is. + } finally { + activityLoadingMore = false; + } + } + async function doTeardownPreview(): Promise { if (!confirmTeardownId || tearingDown) return; const cid = confirmTeardownId; @@ -499,6 +537,25 @@ let stopping = $state(false); let starting = $state(false); + // ── Activity timeline (per-app deploy/event feed) ─────── + // Read-only panel: most-recent events for this workload, kept live by + // the global SSE stream. activityNewIds drives the brief fade-in on + // freshly-arrived rows, mirroring the global events page. + const ACTIVITY_PAGE = 25; + let activityEvents = $state([]); + let activityLoading = $state(true); + let activityNewIds = $state>(new Set()); + let activityOffset = $state(0); + let activityHasMore = $state(false); + let activityLoadingMore = $state(false); + // Client-side severity filter over the already-loaded rows (no refetch). + let activitySeverity = $state<'all' | 'error'>('all'); + const visibleActivity = $derived( + activitySeverity === 'all' + ? activityEvents + : activityEvents.filter((e) => e.severity === activitySeverity) + ); + // Sequence tokens + abort controllers so a slow in-flight probe // cannot overwrite a faster newer one's result, and so an in-flight // request is cancelled when the page unmounts (the user navigates @@ -884,6 +941,10 @@ // each preview child's slug-prefixed URL from its full record. void loadPreviewMeta(); + // Fire-and-forget activity-timeline load. Failure is swallowed + // inside loadActivity; the panel falls back to its empty state. + void loadActivity(); + // Fire-and-forget runtime / storage probes for static workloads. // Failure is captured into their dedicated *_error fields and // must not break the rest of the detail page render. @@ -1402,6 +1463,25 @@ if (!currentId) return; const conn = connectGlobalEvents({ buildLogWorkloadId: currentId, + onEventLog: (payload) => { + // EventLog frames broadcast to EVERY connection (only high-volume + // build logs are workload-filtered server-side), so scope to this + // app client-side before prepending to the activity timeline. + if (payload.workload_id !== currentId) return; + const entry = toEventLogEntry(payload); + // Skip rows already shown (e.g. one that load-more just paged in) + // — {#each (entry.id)} requires unique keys. + if (activityEvents.some((e) => e.id === entry.id)) return; + // Bound generously so a live prepend can't truncate paged-in + // history (load-more grows the list intentionally). + activityEvents = [entry, ...activityEvents].slice(0, 500); + // Prune highlight ids to rows still present after the cap. + const present = new Set(activityEvents.map((e) => e.id)); + activityNewIds = new Set([...activityNewIds, entry.id].filter((x) => present.has(x))); + setTimeout(() => { + activityNewIds = new Set([...activityNewIds].filter((x) => x !== entry.id)); + }, 3000); + }, onBuildLog: (payload) => { // Server already filters by workload_id; this is belt-and-braces. if (payload.workload_id !== currentId) return; @@ -2606,6 +2686,80 @@ {/if} + + {#if !editing} +
+
+

+ {$t('apps.detail.activity.title')}. +

+ {$t('apps.detail.activity.subtitle')} + {#if activityEvents.length > 0} +
+ + +
+ {/if} +
+ + {#if activityLoading} +
+ +
+ {:else if activityEvents.length === 0} +

{$t('apps.detail.activity.empty')}

+ {:else if visibleActivity.length === 0} +

{$t('apps.detail.activity.noErrors')}

+ {:else} +
+ {#each visibleActivity as entry (entry.id)} + + {/each} +
+ {/if} + + {#if !activityLoading && activityHasMore} +
+ +
+ {/if} +
+ {/if} + {#if !editing} diff --git a/web/src/routes/events/+page.svelte b/web/src/routes/events/+page.svelte index a9f8006..9989e32 100644 --- a/web/src/routes/events/+page.svelte +++ b/web/src/routes/events/+page.svelte @@ -8,7 +8,7 @@ import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import { toasts } from '$lib/stores/toast'; - import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse'; + import { connectGlobalEvents, toEventLogEntry, type SSEConnection, type EventLogSSEPayload } from '$lib/sse'; import type { EventLogEntry, EventLogStats } from '$lib/types'; import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import EventLogFilter from '$lib/components/EventLogFilter.svelte'; @@ -146,14 +146,7 @@ // ── SSE real-time events ───────────────────────────────────── function handleSSEEvent(payload: EventLogSSEPayload): void { - const newEntry: EventLogEntry = { - id: payload.id, - source: payload.source, - severity: payload.severity as EventLogEntry['severity'], - message: payload.message, - metadata: payload.metadata, - created_at: payload.created_at - }; + const newEntry = toEventLogEntry(payload); // Update stats. stats = {