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:
+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"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user