diff --git a/.env.example b/.env.example index b66014c..5d30343 100644 --- a/.env.example +++ b/.env.example @@ -159,3 +159,6 @@ DOCKER_GID=0 # Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik) TRUSTED_PROXY_CIDR=172.20.0.0/16 + +# Optional: audit log retention in days (default 90; 0 disables pruning) +# AUDIT_RETENTION_DAYS=90 diff --git a/ROADMAP2.md b/ROADMAP2.md index 98bbe8d..2b11bef 100644 --- a/ROADMAP2.md +++ b/ROADMAP2.md @@ -89,7 +89,7 @@ Legend: βœ… done Β· 🟑 partial/different Β· ❌ missing Β· βž– not applicable | Outbound webhooks (collection events, HMAC, custom headers) | βœ… `outbound_webhooks` + `webhook_deliveries`, HMAC `X-Containr-Signature`, wildcard events, 3-retry, UI page | β€” | | Banners (global + per-tenant, dismissible) | ❌ | `banners` table + top bar | | White-label (logo/favicon/title/OG/theme colors) | ❌ | `app_settings.branding` β€” **Containr remains the default brand** | -| Activity feed (icon/severity/category/metadata, 90-day TTL job) | 🟑 `audit_logs` coarser | Enrich + project feed UI | +| Activity feed (icon/severity/category/metadata, 90-day TTL job) | βœ… `/activity` + project view | β€” | | Admin metrics dashboard (users/servers/queued/failed counts) | 🟑 `/admin` exists | Extend | | **Impersonate user** | ❌ | Admin support tool | | In-app docs (content-collections markdown) | βœ… `/docs` | β€” | @@ -282,8 +282,14 @@ and wired refs, one click or one CLI call. headers, enabled)` + `webhook_deliveries` log; HMAC `X-Containr-Signature`; wildcard events on every audited action; CLI + MCP + `/settings/webhooks` page with delivery log + test ping. -- [ ] **Activity feed** β€” enrich `audit_logs` with `severity category - icon label metadata`; per-project timeline + global feed; TTL job. +- [x] **Activity feed** β€” `audit_logs` enriched with `severity` + (`info|success|warning|error`), `category`, human `label`; derived at + write time, classified on read for legacy rows, boot-time backfill. + `GET /activity` (global, visibility-scoped, filters + `since`) and + `GET /projects/:id/activity` (owner/member/approved/admin). Frontend: + `/activity` page with severity chips + project workspace `Activity` + view. `containr activity` + `containr_activity_list`/`containr_project_activity` + MCP tools. Retention worker prunes > `AUDIT_RETENTION_DAYS` (default 90). - [x] **Banners** β€” `banners` table with title/body/level/active/ dismissible/schedule window. `GET /banners/active` for signed-in users; admin CRUD at `/admin/banners`. `BannerBar` renders above the diff --git a/app/backend/internal/api/activity.go b/app/backend/internal/api/activity.go new file mode 100644 index 0000000..4121e70 --- /dev/null +++ b/app/backend/internal/api/activity.go @@ -0,0 +1,300 @@ +package api + +import ( + "context" + "database/sql" + "fmt" + "log" + "net/http" + "os" + "strconv" + "strings" + "time" + + "containr/internal/database" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// Activity feed β€” enriched audit_logs rows (severity/category/label) served +// as a global feed and per-project timelines. Visibility mirrors the +// deployment/operations scoping: own actions, plus events on resources +// inside visible projects (approved / owned / member; admin sees all). + +const activityVisibility = `( + a.user_id = $1::uuid OR $2::bool + OR (a.resource = 'project' AND a.resource_id IN ( + SELECT id FROM projects p WHERE p.is_approved OR p.owner_id = $1::uuid + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid))) + OR (a.resource = 'service' AND a.resource_id IN ( + SELECT s.id FROM services s JOIN projects p ON p.id = s.project_id + WHERE p.is_approved OR p.owner_id = $1::uuid + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid))) + OR (a.resource = 'deployment' AND a.resource_id IN ( + SELECT d.id FROM deployments d + JOIN services s ON s.id = d.service_id + JOIN projects p ON p.id = s.project_id + WHERE p.is_approved OR p.owner_id = $1::uuid + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid))) + OR (a.resource = 'database' AND a.resource_id IN ( + SELECT ds.id::uuid FROM database_services ds JOIN projects p ON p.id = ds.project_id + WHERE p.is_approved OR p.owner_id = $1::uuid + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $1::uuid))) +)` + +const activitySelect = `SELECT + a.id, COALESCE(a.user_id::text, ''), COALESCE(u.email, ''), + a.resource, COALESCE(a.resource_id::text, ''), a.action, + COALESCE(a.details::text, '{}'), + COALESCE(a.ip_address::text, ''), COALESCE(a.user_agent, ''), + COALESCE(a.severity, 'info'), COALESCE(a.category, ''), COALESCE(a.label, ''), + a.created_at + FROM audit_logs a + LEFT JOIN users u ON u.id = a.user_id` + +func scanActivityRows(rows *sql.Rows) []AuditLog { + var logs []AuditLog + for rows.Next() { + var l AuditLog + if err := rows.Scan(&l.ID, &l.UserID, &l.UserEmail, &l.Resource, &l.ResourceID, + &l.Action, &l.Details, &l.IPAddress, &l.UserAgent, + &l.Severity, &l.Category, &l.Label, &l.CreatedAt); err != nil { + continue + } + // Rows written before enrichment carry empty metadata β€” classify on read. + if l.Label == "" || l.Category == "" { + sev, cat, label := classifyAuditEvent(l.Resource, l.Action) + if l.Severity == "" || l.Severity == "info" { + l.Severity = sev + } + if l.Category == "" { + l.Category = cat + } + if l.Label == "" { + l.Label = label + } + } + logs = append(logs, l) + } + return logs +} + +// GET /activity β€” global feed. Filters: severity, category, resource, +// project_id, since, page, limit. +func handleGetActivity(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + isAdmin := contextIsAdmin(c) + + page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) + limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50) + if limit > 200 { + limit = 200 + } + offset := (page - 1) * limit + + conditions := []string{activityVisibility} + args := []interface{}{userID, isAdmin} + nextArg := 3 + + if v := strings.TrimSpace(c.Query("severity")); v != "" { + conditions = append(conditions, fmt.Sprintf("a.severity = $%d", nextArg)) + args = append(args, v) + nextArg++ + } + if v := strings.TrimSpace(c.Query("category")); v != "" { + conditions = append(conditions, fmt.Sprintf("a.category = $%d", nextArg)) + args = append(args, v) + nextArg++ + } + if v := strings.TrimSpace(c.Query("resource")); v != "" { + conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg)) + args = append(args, v) + nextArg++ + } + if v := strings.TrimSpace(c.Query("project_id")); v != "" { + if _, err := uuid.Parse(v); err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "invalid project_id") + return + } + conditions = append(conditions, fmt.Sprintf(`( + (a.resource = 'project' AND a.resource_id = $%d::uuid) + OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $%d::uuid)) + OR (a.resource = 'deployment' AND a.resource_id IN ( + SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $%d::uuid)) + OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $%d::uuid)) + )`, nextArg, nextArg, nextArg, nextArg)) + args = append(args, v) + nextArg++ + } + if v := strings.TrimSpace(c.Query("since")); v != "" { + sinceTime, err := time.Parse(time.RFC3339, v) + if err != nil { + respondError(c, http.StatusBadRequest, "VALIDATION", "invalid since timestamp, expected RFC3339") + return + } + conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg)) + args = append(args, sinceTime) + nextArg++ + } + + query := fmt.Sprintf(`%s WHERE %s ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, + activitySelect, strings.Join(conditions, " AND "), nextArg, nextArg+1) + args = append(args, limit, offset) + + rows, err := db.Query(query, args...) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity") + return + } + defer rows.Close() + + logs := scanActivityRows(rows) + if logs == nil { + logs = []AuditLog{} + } + c.JSON(http.StatusOK, gin.H{ + "activity": logs, + "page": page, + "limit": limit, + "has_more": len(logs) == limit, + }) +} + +// GET /projects/:id/activity β€” per-project timeline. Resource correlation +// mirrors the global feed, pinned to this project. +func handleGetProjectActivity(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db := c.MustGet("db").(*database.DB) + projectID := strings.TrimSpace(c.Param("id")) + if _, err := uuid.Parse(projectID); err != nil { + respondError(c, http.StatusBadRequest, "INVALID_ID", "invalid project id") + return + } + + // Access check: approved / owner / member / admin. + var visible bool + if err := db.QueryRow( + `SELECT p.is_approved OR p.owner_id = $2::uuid OR $3::bool + OR EXISTS (SELECT 1 FROM project_members pm WHERE pm.project_id = p.id AND pm.user_id = $2::uuid) + FROM projects p WHERE p.id = $1`, + projectID, userID, contextIsAdmin(c), + ).Scan(&visible); err != nil || !visible { + respondError(c, http.StatusNotFound, "NOT_FOUND", "project not found") + return + } + + limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50) + if limit > 200 { + limit = 200 + } + page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) + + rows, err := db.Query(fmt.Sprintf(`%s WHERE ( + (a.resource = 'project' AND a.resource_id = $1::uuid) + OR (a.resource = 'service' AND a.resource_id IN (SELECT id FROM services WHERE project_id = $1::uuid)) + OR (a.resource = 'deployment' AND a.resource_id IN ( + SELECT d.id FROM deployments d JOIN services s ON s.id = d.service_id WHERE s.project_id = $1::uuid)) + OR (a.resource = 'database' AND a.resource_id IN (SELECT id::uuid FROM database_services WHERE project_id = $1::uuid)) + ) ORDER BY a.created_at DESC LIMIT $2 OFFSET $3`, + activitySelect), projectID, limit, (page-1)*limit) + if err != nil { + respondError(c, http.StatusInternalServerError, "INTERNAL", "failed to fetch activity") + return + } + defer rows.Close() + + logs := scanActivityRows(rows) + if logs == nil { + logs = []AuditLog{} + } + c.JSON(http.StatusOK, gin.H{"activity": logs}) +} + +// StartAuditRetention prunes audit rows past the retention window once at +// boot and then daily. AUDIT_RETENTION_DAYS overrides the 90-day default; +// 0 disables pruning entirely. +func StartAuditRetention(ctx context.Context, db *database.DB) { + days := 90 + if raw := strings.TrimSpace(os.Getenv("AUDIT_RETENTION_DAYS")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil { + days = parsed + } + } + // Backfill enrichment for rows written before the classifier existed, + // batched so a large audit table doesn't hold a giant transaction. + go backfillAuditMetadata(db) + + if days <= 0 { + return + } + + prune := func() { + res, err := db.Exec(`DELETE FROM audit_logs WHERE created_at < NOW() - ($1::int * INTERVAL '1 day')`, days) + if err != nil { + log.Printf("audit retention prune failed: %v", err) + return + } + if n, _ := res.RowsAffected(); n > 0 { + log.Printf("audit retention: pruned %d rows older than %d days", n, days) + } + } + prune() + + go func() { + ticker := time.NewTicker(24 * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + prune() + } + } + }() +} + +// backfillAuditMetadata classifies rows written before severity/category/label +// existed so filters and the feed see uniform metadata. +func backfillAuditMetadata(db *database.DB) { + cursor := uuid.Nil.String() + for { + rows, err := db.Query( + `SELECT id::text, resource, action FROM audit_logs + WHERE label = '' AND id > $1::uuid ORDER BY id LIMIT 500`, cursor) + if err != nil { + log.Printf("audit backfill scan failed: %v", err) + return + } + type row struct{ id, resource, action string } + var batch []row + for rows.Next() { + var r row + if rows.Scan(&r.id, &r.resource, &r.action) == nil { + batch = append(batch, r) + } + } + rows.Close() + if len(batch) == 0 { + return + } + for _, r := range batch { + cursor = r.id + sev, cat, label := classifyAuditEvent(r.resource, r.action) + if _, err := db.Exec( + `UPDATE audit_logs SET severity = $1, category = $2, label = $3 WHERE id = $4::uuid`, + sev, cat, label, r.id, + ); err != nil { + log.Printf("audit backfill update failed: %v", err) + } + } + } +} diff --git a/app/backend/internal/api/activity_test.go b/app/backend/internal/api/activity_test.go new file mode 100644 index 0000000..38153b5 --- /dev/null +++ b/app/backend/internal/api/activity_test.go @@ -0,0 +1,32 @@ +package api + +import "testing" + +func TestClassifyAuditEvent(t *testing.T) { + cases := []struct { + resource, action string + wantSev, wantCat, wantLab string + }{ + {"service", "service.create", "success", "service", "Service created"}, + {"service", "service.delete", "warning", "service", "Service deleted"}, + {"deployment", "deploy", "success", "deployment", "Deployment deployed"}, + {"project", "project.create", "success", "project", "Project created"}, + {"user_invite", "user_invite.create", "success", "system", "User invite created"}, + {"user_token", "user_token.revoke", "warning", "security", "User token revoked"}, + {"service", "deploy.fail", "error", "service", "Service fail"}, + {"user", "impersonate", "warning", "user", "User impersonated"}, + {"webhook", "update", "info", "webhook", "Webhook updated"}, + } + for _, tc := range cases { + sev, cat, lab := classifyAuditEvent(tc.resource, tc.action) + if sev != tc.wantSev { + t.Errorf("%s/%s: severity = %q, want %q", tc.resource, tc.action, sev, tc.wantSev) + } + if cat != tc.wantCat { + t.Errorf("%s/%s: category = %q, want %q", tc.resource, tc.action, cat, tc.wantCat) + } + if lab != tc.wantLab { + t.Errorf("%s/%s: label = %q, want %q", tc.resource, tc.action, lab, tc.wantLab) + } + } +} diff --git a/app/backend/internal/api/audit.go b/app/backend/internal/api/audit.go index ac3b0f2..2f7842a 100644 --- a/app/backend/internal/api/audit.go +++ b/app/backend/internal/api/audit.go @@ -23,9 +23,67 @@ type AuditLog struct { Details string `json:"details" db:"details"` IPAddress string `json:"ip_address" db:"ip_address"` UserAgent string `json:"user_agent" db:"user_agent"` + Severity string `json:"severity" db:"severity"` + Category string `json:"category" db:"category"` + Label string `json:"label" db:"label"` CreatedAt time.Time `json:"created_at" db:"created_at"` } +// auditVerbs maps action verbs to past-tense words for feed labels. +var auditVerbs = map[string]string{ + "create": "created", "delete": "deleted", "update": "updated", + "deploy": "deployed", "clone": "cloned", "move": "moved", + "backup": "backed up", "restore": "restored", "revoke": "revoked", + "impersonate": "impersonated", "provision": "provisioned", + "accept": "accepted", "login": "logged in", "scale": "scaled", + "sleep": "put to sleep", "wake": "woke", "approve": "approved", + "set_admin": "admin flag changed", "provisioned": "provisioned", +} + +// classifyAuditEvent derives severity/category/label for the activity feed. +// Actions may already carry the resource prefix ("service.clone") β€” it is +// stripped before matching. +func classifyAuditEvent(resource, action string) (severity, category, label string) { + verb := action + if idx := strings.LastIndex(verb, "."); idx >= 0 { + verb = verb[idx+1:] + } + + category = resource + switch resource { + case "user_invite", "invite", "github_app", "banner", "settings": + category = "system" + case "user_token", "agent_token", "registry_credential": + category = "security" + } + + severity = "info" + switch { + case strings.Contains(verb, "fail"), strings.Contains(verb, "error"), strings.Contains(verb, "denied"): + severity = "error" + case strings.Contains(verb, "delete"), strings.Contains(verb, "revoke"), + strings.Contains(verb, "remove"), strings.Contains(verb, "disable"), + strings.Contains(verb, "impersonate"): + severity = "warning" + case strings.Contains(verb, "create"), strings.Contains(verb, "success"), + strings.Contains(verb, "complete"), strings.Contains(verb, "accept"), + strings.Contains(verb, "provision"), strings.Contains(verb, "clone"), + strings.Contains(verb, "deploy"), strings.Contains(verb, "approve"): + severity = "success" + } + + past := verb + if v, ok := auditVerbs[verb]; ok { + past = v + } + resName := strings.ReplaceAll(resource, "_", " ") + if resName != "" { + resName = strings.ToUpper(resName[:1]) + resName[1:] + } + label = resName + " " + past + return severity, category, label +} + type AuditLogDetail struct { OldValue interface{} `json:"old_value,omitempty"` NewValue interface{} `json:"new_value,omitempty"` @@ -42,12 +100,13 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in detailsJSON, _ := json.Marshal(details) resourceUUID := parseUUIDOrNil(resourceID) userUUID := parseUUIDOrNil(userID) + severity, category, label := classifyAuditEvent(resource, action) auditID := uuid.New().String() _, err := db.Exec( - `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at) - VALUES ($1, $2, $3, $4, $5, $6, $7)`, - auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), time.Now().UTC(), + `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, severity, category, label, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, + auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(), ) if err != nil { @@ -59,6 +118,9 @@ func LogAudit(userID, resource, resourceID, action string, details map[string]in func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) { userID, _ := c.Get("user_id") + if details == nil { + details = map[string]interface{}{} + } details["ip_address"] = c.ClientIP() details["user_agent"] = c.GetHeader("User-Agent") @@ -71,12 +133,13 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de } userUUID := parseUUIDOrNil(userIDStr) resourceUUID := parseUUIDOrNil(resourceID) + severity, category, label := classifyAuditEvent(resource, action) auditID := uuid.New().String() _, err := db.Exec( - `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at) - VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9)`, - auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), time.Now().UTC(), + `INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, severity, category, label, created_at) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12)`, + auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(), ) if err != nil { @@ -155,6 +218,9 @@ func handleGetAuditLogs(c *gin.Context) { COALESCE(a.details::text, '{}'), COALESCE(a.ip_address::text, ''), COALESCE(a.user_agent, ''), + COALESCE(a.severity, 'info'), + COALESCE(a.category, ''), + COALESCE(a.label, ''), a.created_at FROM audit_logs a LEFT JOIN users u ON u.id = a.user_id @@ -173,7 +239,7 @@ func handleGetAuditLogs(c *gin.Context) { var logs []AuditLog for rows.Next() { var log AuditLog - err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) + err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.Severity, &log.Category, &log.Label, &log.CreatedAt) if err != nil { continue } diff --git a/app/backend/internal/api/projects.go b/app/backend/internal/api/projects.go index 037bb11..24fb7b9 100644 --- a/app/backend/internal/api/projects.go +++ b/app/backend/internal/api/projects.go @@ -153,6 +153,7 @@ func handleCreateProject(c *gin.Context) { return } + LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name}) c.JSON(http.StatusCreated, mapSQLCProject(projectRow)) } @@ -340,6 +341,7 @@ func handleDeleteProject(c *gin.Context) { return } + LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil) c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"}) } diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 3d6f8fc..4103c24 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -106,6 +106,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Sleep sweeper β€” scales idle services to zero and prunes wake containers. StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) + // Audit retention β€” prunes rows past AUDIT_RETENTION_DAYS (default 90). + StartAuditRetention(context.Background(), db) + // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) @@ -345,6 +348,8 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Cross-cutting ops view β€” active/queued/failed work authed.GET("/operations", handleGetOperations) + authed.GET("/activity", handleGetActivity) + authed.GET("/projects/:id/activity", handleGetProjectActivity) // Instance announcements shown to all signed-in users authed.GET("/banners/active", handleListActiveBanners) diff --git a/app/backend/internal/cli/commands/activity.go b/app/backend/internal/cli/commands/activity.go new file mode 100644 index 0000000..2ee8917 --- /dev/null +++ b/app/backend/internal/cli/commands/activity.go @@ -0,0 +1,67 @@ +package commands + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" +) + +var ( + activitySeverity string + activityCategory string + activityResource string + activityProject string + activityLimit int +) + +// ActivityCmd shows the enriched activity feed (audit events with +// severity/category/label). +var ActivityCmd = &cobra.Command{ + Use: "activity", + Short: "Activity feed β€” enriched audit events", + RunE: func(cmd *cobra.Command, args []string) error { + c, err := client() + if err != nil { + return err + } + params := []string{fmt.Sprintf("limit=%d", activityLimit)} + if activitySeverity != "" { + params = append(params, "severity="+activitySeverity) + } + if activityCategory != "" { + params = append(params, "category="+activityCategory) + } + if activityResource != "" { + params = append(params, "resource="+activityResource) + } + if activityProject != "" { + params = append(params, "project_id="+activityProject) + } + data, err := c.Do("GET", "/activity?"+strings.Join(params, "&"), nil) + if err != nil { + return err + } + items, err := unwrapList(data, "activity") + if err != nil { + return err + } + rows := make([][]string, 0, len(items)) + for _, e := range items { + rows = append(rows, []string{ + str(e, "severity"), str(e, "category"), str(e, "label"), + str(e, "user_email"), relTime(e, "created_at"), + }) + } + printRows(data, []string{"SEVERITY", "CATEGORY", "EVENT", "ACTOR", "WHEN"}, rows) + return nil + }, +} + +func init() { + ActivityCmd.Flags().StringVar(&activitySeverity, "severity", "", "filter: info|success|warning|error") + ActivityCmd.Flags().StringVar(&activityCategory, "category", "", "filter by category") + ActivityCmd.Flags().StringVar(&activityResource, "resource", "", "filter by resource type") + ActivityCmd.Flags().StringVar(&activityProject, "project", "", "filter by project id") + ActivityCmd.Flags().IntVar(&activityLimit, "limit", 50, "max events") +} diff --git a/app/backend/internal/cli/root.go b/app/backend/internal/cli/root.go index ea2ab3f..62b6eb3 100644 --- a/app/backend/internal/cli/root.go +++ b/app/backend/internal/cli/root.go @@ -89,6 +89,7 @@ func init() { rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.OperationsCmd) + rootCmd.AddCommand(commands.ActivityCmd) rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.UpCmd) diff --git a/app/backend/internal/mcpserver/server.go b/app/backend/internal/mcpserver/server.go index 696cf2e..ca7aaea 100644 --- a/app/backend/internal/mcpserver/server.go +++ b/app/backend/internal/mcpserver/server.go @@ -165,6 +165,8 @@ func init() { register(spec{"containr_github_app_status", "Show GitHub App provisioning status (admin).", "GET", "/admin/git/github-app", nil, nil, false}) register(spec{"containr_github_app_manifest", "Build a GitHub App manifest for self-provisioning (admin). Args: base_url (required, https public URL), name (optional), organization (optional).", "POST", "/admin/git/github-app/manifest", nil, []string{"base_url", "name", "organization"}, false}) register(spec{"containr_github_app_convert", "Exchange the GitHub manifest callback code for app credentials (admin). Args: code.", "POST", "/admin/git/github-app/convert", nil, []string{"code"}, false}) + register(spec{"containr_activity_list", "Activity feed β€” enriched audit events (severity/category/label). Args: severity, category, resource, project_id, since, page, limit.", "GET", "/activity", nil, []string{"severity", "category", "resource", "project_id", "since", "page", "limit"}, false}) + register(spec{"containr_project_activity", "Per-project activity timeline. Args: id (project id), page, limit.", "GET", "/projects/{id}/activity", []string{"id"}, []string{"page", "limit"}, false}) // Nodes / agent tokens (admin) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) diff --git a/app/backend/migrations_goose/20261106000000_audit_enrichment.sql b/app/backend/migrations_goose/20261106000000_audit_enrichment.sql new file mode 100644 index 0000000..67b788c --- /dev/null +++ b/app/backend/migrations_goose/20261106000000_audit_enrichment.sql @@ -0,0 +1,15 @@ +-- +goose Up +ALTER TABLE audit_logs + ADD COLUMN IF NOT EXISTS severity TEXT NOT NULL DEFAULT 'info', + ADD COLUMN IF NOT EXISTS category TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS label TEXT NOT NULL DEFAULT ''; + +CREATE INDEX IF NOT EXISTS idx_audit_logs_feed + ON audit_logs (created_at DESC) INCLUDE (severity, category, resource, action); + +-- +goose Down +DROP INDEX IF EXISTS idx_audit_logs_feed; +ALTER TABLE audit_logs + DROP COLUMN IF EXISTS severity, + DROP COLUMN IF EXISTS category, + DROP COLUMN IF EXISTS label; diff --git a/app/frontend/src/app/App.tsx b/app/frontend/src/app/App.tsx index 24b143f..125b04b 100644 --- a/app/frontend/src/app/App.tsx +++ b/app/frontend/src/app/App.tsx @@ -11,6 +11,7 @@ import { TemplatesPage } from '@/features/templates/pages/TemplatesPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage'; import { OperationsPage } from '@/features/operations/pages/OperationsPage'; +import { ActivityPage } from '@/features/activity/pages/ActivityPage'; import { AdminPage } from '@/features/admin/pages/AdminPage'; import { LandingPage } from '@/features/landing/LandingPage'; import { useBranding } from '@/lib/use-branding'; @@ -148,6 +149,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/app/frontend/src/app/layout/PlatformShell.tsx b/app/frontend/src/app/layout/PlatformShell.tsx index 73a5708..24af13f 100644 --- a/app/frontend/src/app/layout/PlatformShell.tsx +++ b/app/frontend/src/app/layout/PlatformShell.tsx @@ -9,6 +9,7 @@ import { ChartBar, CheckCircle2, ChevronsLeft, + Clock, Container, Database, FileText, @@ -59,6 +60,7 @@ const navSections: NavSection[] = [ title: 'Operate', items: [ { label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, + { label: 'Activity', href: '/activity', icon: Clock, hint: 'Platform event feed' }, { label: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, diff --git a/app/frontend/src/features/activity/pages/ActivityPage.tsx b/app/frontend/src/features/activity/pages/ActivityPage.tsx new file mode 100644 index 0000000..3076911 --- /dev/null +++ b/app/frontend/src/features/activity/pages/ActivityPage.tsx @@ -0,0 +1,121 @@ +import { useQuery } from '@tanstack/react-query'; +import { useState } from 'react'; +import { listActivity, type ActivityEntry } from '@/lib/api-client'; +import { useDemoMode } from '@/lib/demo-mode'; +import { formatRelative } from '@/lib/time'; +import { DemoRestricted } from '@/shared/components'; +import { + Activity, AlertCircle, CheckCircle2, Database, GitBranch, Info, Key, + Loader2, Package, Projector, Rocket, Shield, User, Users, Wrench, +} from 'lucide-react'; + +const CATEGORY_ICONS: Record = { + service: Rocket, deployment: Package, project: Projector, + database: Database, user: User, team: Users, auth: Key, + security: Shield, git: GitBranch, webhook: GitBranch, system: Wrench, +}; + +const SEVERITY_STYLE: Record = { + error: { icon: AlertCircle, cls: 'text-[var(--error)]', dot: 'bg-[var(--error)]' }, + warning: { icon: AlertCircle, cls: 'text-[var(--warning)]', dot: 'bg-[var(--warning)]' }, + success: { icon: CheckCircle2, cls: 'text-[var(--success)]', dot: 'bg-[var(--success)]' }, + info: { icon: Info, cls: 'text-[var(--accent-primary)]', dot: 'bg-[var(--accent-primary)]' }, +}; + +const SEVERITIES = ['', 'error', 'warning', 'success', 'info'] as const; + +export function ActivityPage() { + const isDemoMode = useDemoMode(); + const [severity, setSeverity] = useState(''); + const [page, setPage] = useState(1); + + const query = useQuery({ + queryKey: ['activity-feed', severity, page], + queryFn: () => listActivity({ severity: severity || undefined, page, limit: 50 }), + enabled: !isDemoMode, + refetchInterval: 15000, + }); + + if (isDemoMode) return ; + + const entries = query.data?.activity ?? []; + + return ( +
+
+
+

Activity_

+

+ platform events across your visible projects +

+
+
+ +
+
+ {SEVERITIES.map((s) => ( + + ))} + {query.isFetching && } +
+ +
+ {query.isLoading &&

Loading…

} + {!query.isLoading && entries.length === 0 && ( +

No events yet β€” activity appears here as you deploy, configure, and manage services.

+ )} + {entries.map((e) => )} +
+ +
+ + +
+
+
+ ); +} + +function Entry({ entry }: { entry: ActivityEntry }) { + const Cat = CATEGORY_ICONS[entry.category] ?? Activity; + const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info; + return ( +
+
+ +
+
+
+ {entry.label || entry.action} + {entry.severity} +
+
+ {entry.category} Β· {entry.action}{entry.user_email ? ` Β· ${entry.user_email}` : ''} +
+
+ {formatRelative(entry.created_at)} +
+ ); +} diff --git a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx index c55b75d..3119f15 100644 --- a/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx +++ b/app/frontend/src/features/workspace/pages/ProjectWorkspacePage.tsx @@ -13,6 +13,7 @@ import { listGitBranches, listGitProviders, listGitRepositories, + listProjectActivity, listServiceLogs, listServiceVariables, listServicesByProject, @@ -49,12 +50,13 @@ import { FileUp, } from 'lucide-react'; -type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'settings'; +type WorkspaceView = 'canvas' | 'observability' | 'logs' | 'activity' | 'settings'; const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [ { key: 'canvas', label: 'Canvas', icon: LayoutGrid }, { key: 'observability', label: 'Observability', icon: Activity }, { key: 'logs', label: 'Logs', icon: FileText, authOnly: true }, + { key: 'activity', label: 'Activity', icon: Clock, authOnly: true }, { key: 'settings', label: 'Settings', icon: Settings }, ]; @@ -640,6 +642,13 @@ export function ProjectWorkspacePage() { retry: false, }); + const activityQuery = useQuery({ + queryKey: ['project-activity', projectId], + queryFn: () => listProjectActivity(projectId!), + enabled: !isDemoMode && signedIn && activeView === 'activity' && !!projectId, + refetchInterval: 15000, + }); + const runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]); const serviceHref = (serviceId: string) => isDemoMode @@ -942,6 +951,42 @@ export function ProjectWorkspacePage() { )} + {activeView === 'activity' && ( +
+
+
+ +
+
+

Activity

+

Project timeline β€” deploys, config, membership

+
+
+
+ {activityQuery.isLoading ? ( +

Loading…

+ ) : (activityQuery.data?.activity?.length ?? 0) === 0 ? ( +

No project events yet.

+ ) : ( + activityQuery.data!.activity.map((e) => ( +
+ +
+ {e.label || e.action} + {e.user_email} +
+ {formatRelative(e.created_at)} +
+ )) + )} +
+
+ )} + {activeView === 'settings' && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index 8289a58..e94224e 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -3866,6 +3866,111 @@ export interface paths { patch?: never; trace?: never; }; + "/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Activity feed + * @description Enriched audit events (severity, category, label) scoped to the caller's visible projects; admins see everything. + */ + get: { + parameters: { + query?: { + severity?: "info" | "success" | "warning" | "error"; + category?: string; + resource?: string; + project_id?: string; + since?: string; + page?: number; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Feed entries */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + activity?: components["schemas"]["AuditLog"][]; + page?: number; + limit?: number; + has_more?: boolean; + }; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/projects/{id}/activity": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Per-project activity timeline + * @description Audit events correlated to this project's services, deployments, databases and the project record itself. + */ + get: { + parameters: { + query?: { + page?: number; + limit?: number; + }; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Feed entries */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + activity?: components["schemas"]["AuditLog"][]; + }; + }; + }; + /** @description Project not found or not visible */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/banners/active": { parameters: { query?: never; @@ -10954,6 +11059,15 @@ export interface components { ip_address?: string; /** @description Source user agent */ user_agent?: string; + /** + * @description Event severity for feed display + * @enum {string} + */ + severity?: "info" | "success" | "warning" | "error"; + /** @description Coarse category (service, deployment, project, user, security, system, …) */ + category?: string; + /** @description Human-readable event label */ + label?: string; /** * Format: date-time * @description Timestamp when the audit log entry was created diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index a9458a3..37a7458 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1928,6 +1928,44 @@ export async function getOperations(): Promise { return requestJson('/operations'); } +export type ActivityEntry = { + id: string; + user_email: string; + resource: string; + resource_id: string; + action: string; + details?: string; + severity: 'info' | 'success' | 'warning' | 'error'; + category: string; + label: string; + created_at: string; +}; + +export type ActivityPage = { + activity: ActivityEntry[]; + page?: number; + has_more?: boolean; +}; + +export async function listActivity(filters: { + severity?: string; category?: string; resource?: string; + project_id?: string; page?: number; limit?: number; +} = {}): Promise { + const params = new URLSearchParams(); + if (filters.severity) params.set('severity', filters.severity); + if (filters.category) params.set('category', filters.category); + if (filters.resource) params.set('resource', filters.resource); + if (filters.project_id) params.set('project_id', filters.project_id); + if (filters.page) params.set('page', String(filters.page)); + if (filters.limit) params.set('limit', String(filters.limit)); + const q = params.toString(); + return requestJson(`/activity${q ? `?${q}` : ''}`); +} + +export async function listProjectActivity(projectId: string, page = 1): Promise { + return requestJson(`/projects/${projectId}/activity?page=${page}&limit=50`); +} + export type Banner = { id: string; title: string; diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 27fac19..3d58648 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2620,6 +2620,67 @@ paths: items: type: object + /activity: + get: + tags: + - Operations + summary: Activity feed + description: >- + Enriched audit events (severity, category, label) scoped to the + caller's visible projects; admins see everything. + security: + - bearerAuth: [] + parameters: + - {name: severity, in: query, schema: {type: string, enum: [info, success, warning, error]}} + - {name: category, in: query, schema: {type: string}} + - {name: resource, in: query, schema: {type: string}} + - {name: project_id, in: query, schema: {type: string, format: uuid}} + - {name: since, in: query, schema: {type: string, format: date-time}} + - {name: page, in: query, schema: {type: integer, default: 1}} + - {name: limit, in: query, schema: {type: integer, default: 50, maximum: 200}} + responses: + '200': + description: Feed entries + content: + application/json: + schema: + type: object + properties: + activity: + type: array + items: + $ref: '#/components/schemas/AuditLog' + page: {type: integer} + limit: {type: integer} + has_more: {type: boolean} + + /projects/{id}/activity: + get: + tags: + - Operations + summary: Per-project activity timeline + description: Audit events correlated to this project's services, deployments, databases and the project record itself. + security: + - bearerAuth: [] + parameters: + - {name: id, in: path, required: true, schema: {type: string}} + - {name: page, in: query, schema: {type: integer, default: 1}} + - {name: limit, in: query, schema: {type: integer, default: 50, maximum: 200}} + responses: + '200': + description: Feed entries + content: + application/json: + schema: + type: object + properties: + activity: + type: array + items: + $ref: '#/components/schemas/AuditLog' + '404': + description: Project not found or not visible + /banners/active: get: tags: @@ -7584,6 +7645,16 @@ components: user_agent: type: string description: Source user agent + severity: + type: string + enum: [info, success, warning, error] + description: Event severity for feed display + category: + type: string + description: Coarse category (service, deployment, project, user, security, system, …) + label: + type: string + description: Human-readable event label created_at: type: string format: date-time diff --git a/docs/guides/CLI.md b/docs/guides/CLI.md index 6d8557a..717ac9b 100644 --- a/docs/guides/CLI.md +++ b/docs/guides/CLI.md @@ -84,6 +84,8 @@ webhooks list|create|update|delete|deliveries|test deliveries are HMAC-signed (X-Containr-Signature) operations live ops view: active deploys, queue depth, 24h failures, cron runs, backups +activity enriched activity feed; --severity/--category/ + --resource/--project/--limit filters banners list|create|update|delete announcement banners (admin); active banners print on `auth status` invites list|create|revoke team invite links (admin); create