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)
|
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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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).
|
// 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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)`,
|
||||||
|
|||||||
@@ -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
|
// `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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "Последняя успешная синхронизация репозитория и текущее состояние контейнера.",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user