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
+30
View File
@@ -37,6 +37,36 @@ func (s *Server) listEventLog(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, events) 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. // getEventLogStats handles GET /api/events/log/stats.
func (s *Server) getEventLogStats(w http.ResponseWriter, r *http.Request) { func (s *Server) getEventLogStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.GetEventStats() stats, err := s.store.GetEventStats()
+5
View File
@@ -334,6 +334,11 @@ func (s *Server) Router() chi.Router {
r.Get("/runtime-state", s.getWorkloadRuntimeState) r.Get("/runtime-state", s.getWorkloadRuntimeState)
r.Get("/storage", s.getWorkloadStorage) 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; // Per-workload env vars. Listing open to authenticated readers;
// mutations admin-gated. Encrypted values are write-only after store. // mutations admin-gated. Encrypted values are write-only after store.
r.Get("/env", s.listWorkloadEnv) r.Get("/env", s.listWorkloadEnv)
+7 -6
View File
@@ -69,12 +69,13 @@ type DeployStatusPayload struct {
// EventLogPayload is the payload for EventLog events (audit trail). // EventLogPayload is the payload for EventLog events (audit trail).
type EventLogPayload struct { type EventLogPayload struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Source string `json:"source"` Source string `json:"source"`
Severity string `json:"severity"` WorkloadID string `json:"workload_id"`
Message string `json:"message"` Severity string `json:"severity"`
Metadata string `json:"metadata"` Message string `json:"message"`
CreatedAt string `json:"created_at"` Metadata string `json:"metadata"`
CreatedAt string `json:"created_at"`
} }
// StaticSiteStatusPayload is the payload for EventStaticSiteStatus events. // StaticSiteStatusPayload is the payload for EventStaticSiteStatus events.
+16 -11
View File
@@ -7,12 +7,13 @@ import (
// EventLogFilter holds optional filters for listing event log entries. // EventLogFilter holds optional filters for listing event log entries.
type EventLogFilter struct { type EventLogFilter struct {
Severity string // Filter by severity (info, warn, error). Severity string // Filter by severity (info, warn, error).
Source string // Filter by source. Source string // Filter by source.
Since string // Only events created at or after this timestamp. WorkloadID string // Filter by owning workload (exact match).
Until string // Only events created at or before this timestamp. Since string // Only events created at or after this timestamp.
Limit int // Maximum number of results (default 50). Until string // Only events created at or before this timestamp.
Offset int // Offset for pagination. Limit int // Maximum number of results (default 50).
Offset int // Offset for pagination.
} }
// EventLogStats holds counts of event log entries by severity. // 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( result, err := s.db.Exec(
`INSERT INTO event_log (source, severity, message, metadata, created_at) `INSERT INTO event_log (source, workload_id, severity, message, metadata, created_at)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?)`,
evt.Source, evt.Severity, evt.Message, evt.Metadata, evt.CreatedAt, evt.Source, evt.WorkloadID, evt.Severity, evt.Message, evt.Metadata, evt.CreatedAt,
) )
if err != nil { if err != nil {
return EventLog{}, fmt.Errorf("insert event: %w", err) 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, ",")+")") conditions = append(conditions, "source IN ("+strings.Join(placeholders, ",")+")")
} }
} }
if filter.WorkloadID != "" {
conditions = append(conditions, "workload_id = ?")
args = append(args, filter.WorkloadID)
}
if filter.Since != "" { if filter.Since != "" {
conditions = append(conditions, "created_at >= ?") conditions = append(conditions, "created_at >= ?")
args = append(args, filter.Since) args = append(args, filter.Since)
@@ -90,7 +95,7 @@ func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
args = append(args, filter.Until) 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 { if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ") query += " WHERE " + strings.Join(conditions, " AND ")
} }
@@ -114,7 +119,7 @@ func (s *Store) ListEvents(filter EventLogFilter) ([]EventLog, error) {
events := []EventLog{} events := []EventLog{}
for rows.Next() { for rows.Next() {
var evt EventLog 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) return nil, fmt.Errorf("scan event: %w", err)
} }
events = append(events, evt) 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). // Settings holds global application configuration (single-row pattern).
type Settings struct { type Settings struct {
Domain string `json:"domain"` Domain string `json:"domain"`
ServerIP string `json:"server_ip"` // Docker host IP (for NPM remote forwarding) 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) PublicIP string `json:"public_ip"` // Public-facing IP for DNS A records (e.g., NPM/proxy host)
Network string `json:"network"` Network string `json:"network"`
SubdomainPattern string `json:"subdomain_pattern"` SubdomainPattern string `json:"subdomain_pattern"`
NotificationURL string `json:"notification_url"` NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly NotificationSecret string `json:"-"` // outgoing-webhook signing secret; never serialized directly
NpmURL string `json:"npm_url"` NpmURL string `json:"npm_url"`
NpmEmail string `json:"npm_email"` NpmEmail string `json:"npm_email"`
NpmPassword string `json:"npm_password"` NpmPassword string `json:"npm_password"`
PollingInterval string `json:"polling_interval"` PollingInterval string `json:"polling_interval"`
BaseVolumePath string `json:"base_volume_path"` BaseVolumePath string `json:"base_volume_path"`
SSLCertificateID int `json:"ssl_certificate_id"` SSLCertificateID int `json:"ssl_certificate_id"`
StaleThresholdDays int `json:"stale_threshold_days"` StaleThresholdDays int `json:"stale_threshold_days"`
AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths AllowedVolumePaths string `json:"allowed_volume_paths"` // JSON array of allowed absolute paths
WildcardDNS bool `json:"wildcard_dns"` WildcardDNS bool `json:"wildcard_dns"`
DNSProvider string `json:"dns_provider"` DNSProvider string `json:"dns_provider"`
CloudflareAPIToken string `json:"cloudflare_api_token"` CloudflareAPIToken string `json:"cloudflare_api_token"`
CloudflareZoneID string `json:"cloudflare_zone_id"` CloudflareZoneID string `json:"cloudflare_zone_id"`
NpmRemote bool `json:"npm_remote"` NpmRemote bool `json:"npm_remote"`
NpmAccessListID int `json:"npm_access_list_id"` NpmAccessListID int `json:"npm_access_list_id"`
ProxyProvider string `json:"proxy_provider"` ProxyProvider string `json:"proxy_provider"`
TraefikEntrypoint string `json:"traefik_entrypoint"` TraefikEntrypoint string `json:"traefik_entrypoint"`
TraefikCertResolver string `json:"traefik_cert_resolver"` TraefikCertResolver string `json:"traefik_cert_resolver"`
TraefikNetwork string `json:"traefik_network"` TraefikNetwork string `json:"traefik_network"`
TraefikAPIURL string `json:"traefik_api_url"` TraefikAPIURL string `json:"traefik_api_url"`
ImagePruneThresholdMB int `json:"image_prune_threshold_mb"` ImagePruneThresholdMB int `json:"image_prune_threshold_mb"`
BackupEnabled bool `json:"backup_enabled"` BackupEnabled bool `json:"backup_enabled"`
BackupIntervalHours int `json:"backup_interval_hours"` BackupIntervalHours int `json:"backup_interval_hours"`
BackupRetentionCount int `json:"backup_retention_count"` BackupRetentionCount int `json:"backup_retention_count"`
// AutoBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB backup // AutoBackupBeforeDeploy creates a "pre-deploy" Tinyforge DB backup
// at the start of every project deploy. Independent of BackupEnabled // at the start of every project deploy. Independent of BackupEnabled
// (which governs the periodic auto-backup cron). // (which governs the periodic auto-backup cron).
AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"` AutoBackupBeforeDeploy bool `json:"auto_backup_before_deploy"`
StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection StatsIntervalSeconds int `json:"stats_interval_seconds"` // 0 disables collection
StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection StatsRetentionHours int `json:"stats_retention_hours"` // 0 disables collection
UpdatedAt string `json:"updated_at"` UpdatedAt string `json:"updated_at"`
} }
// ContainerStatsSample is one persisted sample of container resource usage. // ContainerStatsSample is one persisted sample of container resource usage.
// Cumulative counters (network, block I/O) require differencing two samples // Cumulative counters (network, block I/O) require differencing two samples
// to get rates; CPU is already a percent-since-previous-sample value. // to get rates; CPU is already a percent-since-previous-sample value.
type ContainerStatsSample struct { type ContainerStatsSample struct {
ContainerID string `json:"container_id"` ContainerID string `json:"container_id"`
OwnerType string `json:"owner_type"` // "instance" or "site" OwnerType string `json:"owner_type"` // "instance" or "site"
OwnerID string `json:"owner_id"` OwnerID string `json:"owner_id"`
TS int64 `json:"ts"` // Unix seconds UTC TS int64 `json:"ts"` // Unix seconds UTC
CPUPercent float64 `json:"cpu_percent"` CPUPercent float64 `json:"cpu_percent"`
MemoryUsage int64 `json:"memory_usage"` MemoryUsage int64 `json:"memory_usage"`
MemoryLimit int64 `json:"memory_limit"` MemoryLimit int64 `json:"memory_limit"`
NetworkRxBytes int64 `json:"network_rx_bytes"` NetworkRxBytes int64 `json:"network_rx_bytes"`
NetworkTxBytes int64 `json:"network_tx_bytes"` NetworkTxBytes int64 `json:"network_tx_bytes"`
BlockReadBytes int64 `json:"block_read_bytes"` BlockReadBytes int64 `json:"block_read_bytes"`
BlockWriteBytes int64 `json:"block_write_bytes"` BlockWriteBytes int64 `json:"block_write_bytes"`
} }
// SystemStatsSample is one persisted host-level snapshot that aggregates // SystemStatsSample is one persisted host-level snapshot that aggregates
@@ -106,10 +106,12 @@ type DNSRecord struct {
// page. The legacy field names (ProjectID, ProjectName, StageID, // page. The legacy field names (ProjectID, ProjectName, StageID,
// StageName, InstanceID) are retained verbatim for the existing // StageName, InstanceID) are retained verbatim for the existing
// frontend contract — after the workload-first cutover they map to: // frontend contract — after the workload-first cutover they map to:
// ProjectID/Name → workload id / workload name //
// StageID/Name → containers.stage_id / containers.role // ProjectID/Name → workload id / workload name
// InstanceID → container row id // StageID/Name → containers.stage_id / containers.role
// Source → "instance" for image/compose, "static_site" for static // InstanceID → container row id
// Source → "instance" for image/compose, "static_site" for static
//
// Renaming would require a coordinated frontend change; deferred. // Renaming would require a coordinated frontend change; deferred.
type ProxyRoute struct { type ProxyRoute struct {
Source string `json:"source"` Source string `json:"source"`
@@ -190,12 +192,13 @@ func IsValidVolumeScope(s string) bool {
// EventLog represents a persistent event log entry. // EventLog represents a persistent event log entry.
type EventLog struct { type EventLog struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Source string `json:"source"` Source string `json:"source"`
Severity string `json:"severity"` // info, warn, error WorkloadID string `json:"workload_id"` // "" = unscoped (non-deploy events)
Message string `json:"message"` Severity string `json:"severity"` // info, warn, error
Metadata string `json:"metadata"` // JSON-encoded structured data Message string `json:"message"`
CreatedAt string `json:"created_at"` Metadata string `json:"metadata"` // JSON-encoded structured data
CreatedAt string `json:"created_at"`
} }
// EventTrigger is a filter+action rule evaluated against EventLog // EventTrigger is a filter+action rule evaluated against EventLog
@@ -245,12 +248,12 @@ const (
// for this workload). // for this workload).
type LogScanRule struct { type LogScanRule struct {
ID int64 `json:"id"` ID int64 `json:"id"`
WorkloadID string `json:"workload_id"` // "" = global WorkloadID string `json:"workload_id"` // "" = global
OverridesID int64 `json:"overrides_id"` // 0 = not an override OverridesID int64 `json:"overrides_id"` // 0 = not an override
Name string `json:"name"` Name string `json:"name"`
Pattern string `json:"pattern"` // regex, compiled at load Pattern string `json:"pattern"` // regex, compiled at load
Severity string `json:"severity"` // info|warn|error Severity string `json:"severity"` // info|warn|error
Streams string `json:"streams"` // all|stdout|stderr Streams string `json:"streams"` // all|stdout|stderr
CooldownSeconds int `json:"cooldown_seconds"` CooldownSeconds int `json:"cooldown_seconds"`
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
@@ -308,13 +311,13 @@ type Workload struct {
Kind string `json:"kind"` // project | stack | site (legacy discriminator) Kind string `json:"kind"` // project | stack | site (legacy discriminator)
RefID string `json:"ref_id"` RefID string `json:"ref_id"`
Name string `json:"name"` Name string `json:"name"`
AppID string `json:"app_id"` // nullable; "" = unassigned (a.k.a. GroupID after rename) AppID string `json:"app_id"` // nullable; "" = unassigned (a.k.a. GroupID after rename)
SourceKind string `json:"source_kind"` // "" until plugin-mode populated SourceKind string `json:"source_kind"` // "" until plugin-mode populated
SourceConfig string `json:"source_config"` // JSON-encoded, decoded by the matching Source SourceConfig string `json:"source_config"` // JSON-encoded, decoded by the matching Source
TriggerKind string `json:"trigger_kind"` TriggerKind string `json:"trigger_kind"`
TriggerConfig string `json:"trigger_config"` // JSON-encoded, decoded by the matching Trigger TriggerConfig string `json:"trigger_config"` // JSON-encoded, decoded by the matching Trigger
PublicFaces string `json:"public_faces"` // JSON-encoded []PublicFace PublicFaces string `json:"public_faces"` // JSON-encoded []PublicFace
ParentWorkloadID string `json:"parent_workload_id"` // "" = root; non-empty = stage chain ParentWorkloadID string `json:"parent_workload_id"` // "" = root; non-empty = stage chain
NotificationURL string `json:"notification_url"` NotificationURL string `json:"notification_url"`
NotificationSecret string `json:"-"` // never serialized NotificationSecret string `json:"-"` // never serialized
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
@@ -393,11 +396,11 @@ type Container struct {
// which workloads to fire. // which workloads to fire.
type Trigger struct { type Trigger struct {
ID string `json:"id"` ID string `json:"id"`
Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ... Kind string `json:"kind"` // registry | git | manual | schedule | log_scan | ...
Name string `json:"name"` // human-readable, unique Name string `json:"name"` // human-readable, unique
Config string `json:"config"` // JSON-encoded, decoded by the matching plugin Config string `json:"config"` // JSON-encoded, decoded by the matching plugin
WebhookSecret string `json:"-"` // URL-identifier secret; never serialized WebhookSecret string `json:"-"` // URL-identifier secret; never serialized
WebhookSigningSecret string `json:"-"` // HMAC key; never serialized WebhookSigningSecret string `json:"-"` // HMAC key; never serialized
WebhookRequireSignature bool `json:"webhook_require_signature"` WebhookRequireSignature bool `json:"webhook_require_signature"`
// LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched // LastFiredAt is the RFC3339 wall-clock the scheduler last dispatched
// this trigger. Empty for never-fired or non-schedule triggers. The // this trigger. Empty for never-fired or non-schedule triggers. The
@@ -433,4 +436,3 @@ type App struct {
CreatedAt string `json:"created_at"` CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_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 // Empty string = never fired. Pre-trigger-split DBs land the column
// here so the scheduler can read/write it on first boot. // here so the scheduler can read/write it on first boot.
`ALTER TABLE triggers ADD COLUMN last_fired_at TEXT NOT NULL DEFAULT ''`, `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 // Hard cutover: drop every legacy table. Idempotent — DROP TABLE
// IF EXISTS is a no-op once the table is gone. Operators upgrading // IF EXISTS is a no-op once the table is gone. Operators upgrading
// from a pre-cutover build will lose any project / stack / static // 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_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_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_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_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_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)`, `CREATE INDEX IF NOT EXISTS idx_container_stats_container_ts ON container_stats_samples(container_id, ts)`,
+103
View File
@@ -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: <reason>").
//
// 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,
},
})
}
+167
View File
@@ -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))
}
}
@@ -84,7 +84,7 @@ func (*source) Validate(cfg json.RawMessage) error {
// `docker compose -p <project> up -d`, then syncs one Container row per // `docker compose -p <project> up -d`, then syncs one Container row per
// service. The workload ID is the natural compose project name unless // service. The workload ID is the natural compose project name unless
// the user supplied one explicitly. // 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) cfg, err := plugin.SourceConfigOf[Config](w)
if err != nil { if err != nil {
return fmt.Errorf("compose source: decode config: %w", err) 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) 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) projectName := composeProjectName(cfg.ComposeProjectName, w)
yamlPath, err := writeYAML(w.ID, cfg.ComposeYAML) yamlPath, err := writeYAML(w.ID, cfg.ComposeYAML)
if err != nil { if err != nil {
@@ -2,7 +2,6 @@ package dockerfile
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "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 // publishEvent records a workload-scoped deploy event in the audit log.
// event_log row. Message shape mirrors the static plugin // The InsertEvent + bus publish (and consistent message/metadata shape
// ("Build %q: %s") so the dashboard's audit feed reads consistently // across source kinds) is centralised in plugin.EmitDeployEvent so the
// across both kinds. // 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) { func publishEvent(deps plugin.Deps, w plugin.Workload, status string) {
severity := "info" plugin.EmitDeployEvent(deps, w, "dockerfile", status)
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,
},
})
} }
// publishBuildLog emits one EventBuildLog per non-empty daemon "stream" // publishBuildLog emits one EventBuildLog per non-empty daemon "stream"
+30 -32
View File
@@ -118,7 +118,7 @@ func (*source) Validate(cfg json.RawMessage) error {
// //
// Any failure between create and face-registration rolls back the new // Any failure between create and face-registration rolls back the new
// container + its row; old serving state is preserved. // 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) cfg, err := plugin.SourceConfigOf[Config](w)
if err != nil { if err != nil {
return fmt.Errorf("image source: decode config: %w", err) 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) authConfig, err := buildRegistryAuth(deps, cfg.RegistryName)
if err != nil { if err != nil {
return fmt.Errorf("image source: %w", err) return fmt.Errorf("image source: %w", err)
@@ -486,37 +499,22 @@ type containerExtra struct {
ProxyRoutes map[string]string `json:"proxy_routes,omitempty"` ProxyRoutes map[string]string `json:"proxy_routes,omitempty"`
} }
// Reconcile syncs the containers index for this workload with reality. // Reconcile is intentionally a no-op for the image source.
// MVP: just refreshes State from Docker. Future versions can re-deploy //
// when the running container disagrees with the desired source config. // State sync is fully handled by the generic reconciler pass that runs
func (*source) Reconcile(ctx context.Context, deps plugin.Deps, w plugin.Workload) error { // EARLIER in the same Reconciler.ReconcileOnce: its upsert loop writes each
rows, err := deps.Store.ListContainersByWorkload(w.ID) // present container's State from the single `docker ps -a` snapshot
if err != nil { // (ListAllForReconciler), and its markMissing pass flips rows whose container
return fmt.Errorf("image source: list containers: %w", err) // ID is absent from that snapshot to 'missing'. Every image container carries
} // the tinyforge.workload.id label (ContainerConfig.WorkloadID at create time),
for _, c := range rows { // so the generic pass covers all of them.
if c.ContainerID == "" { //
continue // The previous implementation looped this workload's container rows and called
} // Docker.IsContainerRunning per row — a redundant Docker inspect per container
running, err := deps.Docker.IsContainerRunning(ctx, c.ContainerID) // per tick that duplicated work already done from the snapshot and scaled as N
if err != nil { // Docker API calls/tick. Returning nil here drops that cost without changing
// Most likely "no such container" — mark as missing so the UI // observable state. The method stays because the source interface requires it.
// surfaces it and the next deploy recreates. func (*source) Reconcile(context.Context, plugin.Deps, plugin.Workload) error {
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)
}
}
}
return nil return nil
} }
@@ -2,14 +2,12 @@ package static
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/moby/moby/api/types/mount" "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 // publishEvent emits a static_site_status event on the bus (drives the
// persists an event_log row so the dashboard's audit trail picks it // dashboard's per-site status pill) AND records a workload-scoped deploy
// up. Message format ("Static site \"%s\": %s") is preserved verbatim // event in the audit log. The audit InsertEvent + bus publish is
// from the legacy Manager.publishEvent so log scrapers and operator- // centralised in plugin.EmitDeployEvent so the message/metadata shape and
// configured event triggers keep matching. // 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) { func publishEvent(deps plugin.Deps, w plugin.Workload, status string) {
deps.Events.Publish(events.Event{ deps.Events.Publish(events.Event{
Type: events.EventStaticSiteStatus, Type: events.EventStaticSiteStatus,
@@ -558,47 +558,7 @@ func publishEvent(deps plugin.Deps, w plugin.Workload, status string) {
}, },
}) })
severity := "info" plugin.EmitDeployEvent(deps, w, "static_site", status)
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,
},
})
} }
// removeContainerByName mirrors the legacy helper: enumerate Docker's // removeContainerByName mirrors the legacy helper: enumerate Docker's
+13
View File
@@ -765,6 +765,19 @@ export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats>
return get<EventLogStats>('/api/events/log/stats', signal); return get<EventLogStats>('/api/events/log/stats', signal);
} }
export function fetchWorkloadEvents(
id: string,
params?: { severity?: string; limit?: number; offset?: number },
signal?: AbortSignal
): Promise<EventLogEntry[]> {
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<EventLogEntry[]>(`/api/workloads/${id}/events${qs ? `?${qs}` : ''}`, signal);
}
export function deleteEvent(id: number): Promise<{ status: string }> { export function deleteEvent(id: number): Promise<{ status: string }> {
return del<{ status: string }>(`/api/events/log/${id}`); return del<{ status: string }>(`/api/events/log/${id}`);
} }
+13
View File
@@ -545,6 +545,9 @@
}, },
"source": { "source": {
"deploy": "Deploy", "deploy": "Deploy",
"image": "Image",
"compose": "Compose",
"dockerfile": "Dockerfile",
"static_site": "Static Site", "static_site": "Static Site",
"stale_scanner": "Stale Scanner", "stale_scanner": "Stale Scanner",
"stale_cleanup": "Stale Cleanup", "stale_cleanup": "Stale Cleanup",
@@ -1406,6 +1409,16 @@
"deployError": "Deploy failed", "deployError": "Deploy failed",
"saveError": "Save failed", "saveError": "Save failed",
"deleteError": "Delete 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": { "runtimeState": {
"title": "Sync status", "title": "Sync status",
"sub": "Last successful sync of the source repo and the current container state.", "sub": "Last successful sync of the source repo and the current container state.",
+13
View File
@@ -545,6 +545,9 @@
}, },
"source": { "source": {
"deploy": "Развёртывание", "deploy": "Развёртывание",
"image": "Образ",
"compose": "Compose",
"dockerfile": "Dockerfile",
"static_site": "Статический сайт", "static_site": "Статический сайт",
"stale_scanner": "Сканер устаревших", "stale_scanner": "Сканер устаревших",
"stale_cleanup": "Очистка устаревших", "stale_cleanup": "Очистка устаревших",
@@ -1406,6 +1409,16 @@
"deployError": "Деплой не удался", "deployError": "Деплой не удался",
"saveError": "Сохранение не удалось", "saveError": "Сохранение не удалось",
"deleteError": "Удаление не удалось", "deleteError": "Удаление не удалось",
"activity": {
"title": "Активность",
"subtitle": "Недавние деплои и события этого приложения",
"empty": "Пока нет активности. Деплои и события появятся здесь.",
"recentNote": "Показана недавняя активность.",
"loadMore": "Загрузить ещё",
"filterAll": "Все",
"filterErrors": "Ошибки",
"noErrors": "Нет ошибок в загруженной активности."
},
"runtimeState": { "runtimeState": {
"title": "Статус синхронизации", "title": "Статус синхронизации",
"sub": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.", "sub": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.",
+21
View File
@@ -6,6 +6,7 @@
*/ */
import { getAuthToken } from './auth'; import { getAuthToken } from './auth';
import type { EventLogEntry } from '$lib/types';
// ── Types ────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────
@@ -41,12 +42,32 @@ export interface DeployStatusPayload {
export interface EventLogSSEPayload { export interface EventLogSSEPayload {
id: number; id: number;
source: string; 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; severity: string;
message: string; message: string;
metadata: string; metadata: string;
created_at: 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 { export interface BuildLogPayload {
workload_id: string; workload_id: string;
line: string; line: string;
+156 -2
View File
@@ -2,7 +2,7 @@
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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 type { RedeployTrigger, WorkloadTriggerBinding } from '$lib/api';
import * as api from '$lib/api'; import * as api from '$lib/api';
import { import {
@@ -26,6 +26,7 @@
} from '$lib/components/icons'; } from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte'; import ForgeHero from '$lib/components/ForgeHero.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte'; import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte'; import ContainerStats from '$lib/components/ContainerStats.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte'; import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
@@ -62,7 +63,7 @@
import { t } from '$lib/i18n'; import { t } from '$lib/i18n';
import { fmt } from '$lib/format/datetime'; import { fmt } from '$lib/format/datetime';
import { formatBytes } from '$lib/format/bytes'; 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 // Route params come back as `string | undefined`; the route file
// guarantees `id` exists, but the empty-string fallback satisfies // guarantees `id` exists, but the empty-string fallback satisfies
@@ -452,6 +453,43 @@
previewMeta = next; 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<void> {
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<void> {
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<void> { async function doTeardownPreview(): Promise<void> {
if (!confirmTeardownId || tearingDown) return; if (!confirmTeardownId || tearingDown) return;
const cid = confirmTeardownId; const cid = confirmTeardownId;
@@ -499,6 +537,25 @@
let stopping = $state(false); let stopping = $state(false);
let starting = $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<EventLogEntry[]>([]);
let activityLoading = $state(true);
let activityNewIds = $state<Set<number>>(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 // Sequence tokens + abort controllers so a slow in-flight probe
// cannot overwrite a faster newer one's result, and so an in-flight // cannot overwrite a faster newer one's result, and so an in-flight
// request is cancelled when the page unmounts (the user navigates // 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. // each preview child's slug-prefixed URL from its full record.
void loadPreviewMeta(); 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. // Fire-and-forget runtime / storage probes for static workloads.
// Failure is captured into their dedicated *_error fields and // Failure is captured into their dedicated *_error fields and
// must not break the rest of the detail page render. // must not break the rest of the detail page render.
@@ -1402,6 +1463,25 @@
if (!currentId) return; if (!currentId) return;
const conn = connectGlobalEvents({ const conn = connectGlobalEvents({
buildLogWorkloadId: currentId, 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) => { onBuildLog: (payload) => {
// Server already filters by workload_id; this is belt-and-braces. // Server already filters by workload_id; this is belt-and-braces.
if (payload.workload_id !== currentId) return; if (payload.workload_id !== currentId) return;
@@ -2606,6 +2686,80 @@
</section> </section>
{/if} {/if}
<!-- ── Activity timeline (recent deploys + events) ──
Read-only feed of the most recent workload-scoped events, kept
live by the global SSE stream's onEventLog callback. -->
{#if !editing}
<section class="panel" aria-labelledby="activity-heading">
<header class="panel-head">
<h2 class="panel-title" id="activity-heading">
{$t('apps.detail.activity.title')}<span class="title-accent">.</span>
</h2>
<span class="panel-sub">{$t('apps.detail.activity.subtitle')}</span>
{#if activityEvents.length > 0}
<div class="ml-auto inline-flex items-center rounded-lg bg-[var(--surface-card-hover)] p-0.5">
<button
type="button"
aria-pressed={activitySeverity === 'all'}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
{activitySeverity === 'all'
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => { activitySeverity = 'all'; }}
>
{$t('apps.detail.activity.filterAll')}
</button>
<button
type="button"
aria-pressed={activitySeverity === 'error'}
class="rounded-md px-2.5 py-1 text-xs font-medium transition-all duration-150
{activitySeverity === 'error'
? 'bg-[var(--surface-card)] text-[var(--text-primary)] shadow-[var(--shadow-sm)]'
: 'text-[var(--text-tertiary)] hover:text-[var(--text-secondary)]'}"
onclick={() => { activitySeverity = 'error'; }}
>
{$t('apps.detail.activity.filterErrors')}
</button>
</div>
{/if}
</header>
{#if activityLoading}
<div class="flex items-center justify-center py-16">
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
</div>
{:else if activityEvents.length === 0}
<p class="hint">{$t('apps.detail.activity.empty')}</p>
{:else if visibleActivity.length === 0}
<p class="hint">{$t('apps.detail.activity.noErrors')}</p>
{:else}
<div
class="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
>
{#each visibleActivity as entry (entry.id)}
<EventLogEntryComponent {entry} isNew={activityNewIds.has(entry.id)} />
{/each}
</div>
{/if}
{#if !activityLoading && activityHasMore}
<div class="flex justify-center pt-2 pb-1">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
onclick={loadMoreActivity}
disabled={activityLoadingMore}
>
{#if activityLoadingMore}
<IconLoader size={16} class="animate-spin" />
{/if}
{$t('apps.detail.activity.loadMore')}
</button>
</div>
{/if}
</section>
{/if}
<!-- ── Per-workload notification routes ───────────── --> <!-- ── Per-workload notification routes ───────────── -->
{#if !editing} {#if !editing}
<WorkloadNotificationsPanel workloadId={id} /> <WorkloadNotificationsPanel workloadId={id} />
+2 -9
View File
@@ -8,7 +8,7 @@
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api'; import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast'; 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 type { EventLogEntry, EventLogStats } from '$lib/types';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte'; import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte'; import EventLogFilter from '$lib/components/EventLogFilter.svelte';
@@ -146,14 +146,7 @@
// ── SSE real-time events ───────────────────────────────────── // ── SSE real-time events ─────────────────────────────────────
function handleSSEEvent(payload: EventLogSSEPayload): void { function handleSSEEvent(payload: EventLogSSEPayload): void {
const newEntry: EventLogEntry = { const newEntry = toEventLogEntry(payload);
id: payload.id,
source: payload.source,
severity: payload.severity as EventLogEntry['severity'],
message: payload.message,
metadata: payload.metadata,
created_at: payload.created_at
};
// Update stats. // Update stats.
stats = { stats = {