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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
+16
-11
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)`,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
// 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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -765,6 +765,19 @@ export function fetchEventLogStats(signal?: AbortSignal): Promise<EventLogStats>
|
||||
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 }> {
|
||||
return del<{ status: string }>(`/api/events/log/${id}`);
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<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> {
|
||||
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<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
|
||||
// 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 @@
|
||||
</section>
|
||||
{/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 ───────────── -->
|
||||
{#if !editing}
|
||||
<WorkloadNotificationsPanel workloadId={id} />
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user