Compare commits

...
1 Commits
Author SHA1 Message Date
Tomáš DvořákandDevin 5eac4a382b feat: activity feed — enriched audit events + global/project timelines (#45)
* feat(branding): white-label — public /branding, admin write, shell theming

app_settings branding.* keys (product name, logo/favicon URLs, accent
color, docs/support links). Public GET /branding so login and shell
theme pre-auth; admin writes via PUT /settings.

useBranding() applies document.title, accent CSS var and favicon;
BrandWordmark replaces hardcoded wordmarks; admin Platform settings
gain a Branding form. MCP containr_branding_get + settings_update.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(invites): team invite links — admin create/revoke, public accept

Admins mint single-use invite links (sha256-hashed token at rest,
optional email binding, configurable TTL). Public endpoints let the
accept page validate the link and register — working even while
public signup is disabled.

- migration + sqlc queries/model for user_invites
- POST /admin/invites (returns one-time token+url), GET list, DELETE revoke
- GET /auth/invites/:token public validation, POST /auth/accept-invite
- CLI containr invites list|create|revoke; MCP invites tools
- /auth/accept-invite page (expired/used/invalid states, bound-email lock)
- Admin page Team invites section: create, copy link, revoke
- OpenAPI + generated types + CLI guide

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(admin): impersonate user — 15-min token, audit-logged

POST /admin/users/:id/impersonate mints a short-lived bearer token
(impersonated_by claim) for support/debugging. Admin accounts cannot
be impersonated; every mint is audit-logged with actor + target.

- CLI: containr admin impersonate <user-id> (works via CONTAINR_TOKEN)
- MCP: containr_admin_impersonate
- Admin UI: per-user Impersonate action → token modal with usage hint
- OpenAPI + generated types

Verified live: token resolves to target, admin target 403, audit row.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(git): GitHub App manifest self-provisioning + app-level webhook

Admins create the instance GitHub App in-browser instead of hand-
setting GITHUB_APP_* env vars:

- POST /admin/git/github-app/manifest builds the manifest + URL;
  the frontend auto-submits it to github.com
- /git/github-app/callback page exchanges the code via
  POST /admin/git/github-app/convert; credentials persist in
  app_settings (precedence over env)
- GET /admin/git/github-app reports configured/source
- POST /api/git/github-app/webhook — single app-level push receiver,
  HMAC-verified, dispatches via extracted dispatchPushToServices
- install-url + installation-token paths resolve settings→env
- CLI: containr admin github-app status|manifest|convert; MCP tools
- Git providers UI shows "Set up GitHub App" for admins when
  unconfigured

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat: activity feed — enriched audit events, global + per-project timelines

audit_logs gains severity/category/label columns derived from
resource+action at write time; legacy rows are classified on read and
backfilled at boot in batches.

- GET /activity: global feed scoped to the caller's visible projects
  (admins see all); severity/category/resource/project_id/since
  filters, page+limit
- GET /projects/:id/activity: timeline correlated to the project's
  services, deployments, databases and the project record; access
  mirrors project read rules
- Retention worker prunes rows older than AUDIT_RETENTION_DAYS
  (default 90, 0 disables), running once at boot then daily
- project.create/delete now audited so timelines aren't empty
- CLI `containr activity` with filter flags; MCP tools
  containr_activity_list + containr_project_activity
- Frontend: /activity page (severity chips, icon per category) under
  Operate nav; project workspace gains an Activity view tab
- OpenAPI + generated types + CLI docs

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-09 10:52:15 +02:00
19 changed files with 905 additions and 11 deletions

No files matched your search

+3
View File
@@ -159,3 +159,6 @@ DOCKER_GID=0
# Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik) # Optional: trust reverse proxy CIDR (default local Docker bridge used by Traefik)
TRUSTED_PROXY_CIDR=172.20.0.0/16 TRUSTED_PROXY_CIDR=172.20.0.0/16
# Optional: audit log retention in days (default 90; 0 disables pruning)
# AUDIT_RETENTION_DAYS=90
+9 -3
View File
@@ -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 | — | | 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 | | 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** | | 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 | | Admin metrics dashboard (users/servers/queued/failed counts) | 🟡 `/admin` exists | Extend |
| **Impersonate user** | ❌ | Admin support tool | | **Impersonate user** | ❌ | Admin support tool |
| In-app docs (content-collections markdown) | ✅ `/docs` | — | | 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 headers, enabled)` + `webhook_deliveries` log; HMAC
`X-Containr-Signature`; wildcard events on every audited action; `X-Containr-Signature`; wildcard events on every audited action;
CLI + MCP + `/settings/webhooks` page with delivery log + test ping. CLI + MCP + `/settings/webhooks` page with delivery log + test ping.
- [ ] **Activity feed** — enrich `audit_logs` with `severity category - [x] **Activity feed** — `audit_logs` enriched with `severity`
icon label metadata`; per-project timeline + global feed; TTL job. (`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/ - [x] **Banners** — `banners` table with title/body/level/active/
dismissible/schedule window. `GET /banners/active` for signed-in dismissible/schedule window. `GET /banners/active` for signed-in
users; admin CRUD at `/admin/banners`. `BannerBar` renders above the users; admin CRUD at `/admin/banners`. `BannerBar` renders above the
+300
View File
@@ -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)
}
}
}
}
+32
View File
@@ -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)
}
}
}
+73 -7
View File
@@ -23,9 +23,67 @@ type AuditLog struct {
Details string `json:"details" db:"details"` Details string `json:"details" db:"details"`
IPAddress string `json:"ip_address" db:"ip_address"` IPAddress string `json:"ip_address" db:"ip_address"`
UserAgent string `json:"user_agent" db:"user_agent"` 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"` 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 { type AuditLogDetail struct {
OldValue interface{} `json:"old_value,omitempty"` OldValue interface{} `json:"old_value,omitempty"`
NewValue interface{} `json:"new_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) detailsJSON, _ := json.Marshal(details)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
userUUID := parseUUIDOrNil(userID) userUUID := parseUUIDOrNil(userID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, created_at) `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)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), severity, category, label, time.Now().UTC(),
) )
if err != nil { 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{}) { func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, details map[string]interface{}) {
userID, _ := c.Get("user_id") userID, _ := c.Get("user_id")
if details == nil {
details = map[string]interface{}{}
}
details["ip_address"] = c.ClientIP() details["ip_address"] = c.ClientIP()
details["user_agent"] = c.GetHeader("User-Agent") details["user_agent"] = c.GetHeader("User-Agent")
@@ -71,12 +133,13 @@ func LogAuditWithRequest(c *gin.Context, resource, resourceID, action string, de
} }
userUUID := parseUUIDOrNil(userIDStr) userUUID := parseUUIDOrNil(userIDStr)
resourceUUID := parseUUIDOrNil(resourceID) resourceUUID := parseUUIDOrNil(resourceID)
severity, category, label := classifyAuditEvent(resource, action)
auditID := uuid.New().String() auditID := uuid.New().String()
_, err := db.Exec( _, err := db.Exec(
`INSERT INTO audit_logs (id, user_id, resource, resource_id, action, details, ip_address, user_agent, created_at) `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)`, 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"), time.Now().UTC(), auditID, userUUID, resource, resourceUUID, action, string(detailsJSON), c.ClientIP(), c.GetHeader("User-Agent"), severity, category, label, time.Now().UTC(),
) )
if err != nil { if err != nil {
@@ -155,6 +218,9 @@ func handleGetAuditLogs(c *gin.Context) {
COALESCE(a.details::text, '{}'), COALESCE(a.details::text, '{}'),
COALESCE(a.ip_address::text, ''), COALESCE(a.ip_address::text, ''),
COALESCE(a.user_agent, ''), COALESCE(a.user_agent, ''),
COALESCE(a.severity, 'info'),
COALESCE(a.category, ''),
COALESCE(a.label, ''),
a.created_at a.created_at
FROM audit_logs a FROM audit_logs a
LEFT JOIN users u ON u.id = a.user_id LEFT JOIN users u ON u.id = a.user_id
@@ -173,7 +239,7 @@ func handleGetAuditLogs(c *gin.Context) {
var logs []AuditLog var logs []AuditLog
for rows.Next() { for rows.Next() {
var log AuditLog 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 { if err != nil {
continue continue
} }
+2
View File
@@ -153,6 +153,7 @@ func handleCreateProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name})
c.JSON(http.StatusCreated, mapSQLCProject(projectRow)) c.JSON(http.StatusCreated, mapSQLCProject(projectRow))
} }
@@ -340,6 +341,7 @@ func handleDeleteProject(c *gin.Context) {
return return
} }
LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil)
c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"}) c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"})
} }
+5
View File
@@ -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. // Sleep sweeper — scales idle services to zero and prunes wake containers.
StartSleepSweeper(context.Background(), db, dockerClient, deploymentEngine, cfg.Port) 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 // Initialize database handler
databaseHandler := NewDatabaseHandler(db.DB, dockerClient) 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 // Cross-cutting ops view — active/queued/failed work
authed.GET("/operations", handleGetOperations) authed.GET("/operations", handleGetOperations)
authed.GET("/activity", handleGetActivity)
authed.GET("/projects/:id/activity", handleGetProjectActivity)
// Instance announcements shown to all signed-in users // Instance announcements shown to all signed-in users
authed.GET("/banners/active", handleListActiveBanners) authed.GET("/banners/active", handleListActiveBanners)
@@ -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")
}
+1
View File
@@ -89,6 +89,7 @@ func init() {
rootCmd.AddCommand(commands.NotificationsCmd) rootCmd.AddCommand(commands.NotificationsCmd)
rootCmd.AddCommand(commands.WebhooksCmd) rootCmd.AddCommand(commands.WebhooksCmd)
rootCmd.AddCommand(commands.OperationsCmd) rootCmd.AddCommand(commands.OperationsCmd)
rootCmd.AddCommand(commands.ActivityCmd)
rootCmd.AddCommand(commands.BannersCmd) rootCmd.AddCommand(commands.BannersCmd)
rootCmd.AddCommand(commands.InvitesCmd) rootCmd.AddCommand(commands.InvitesCmd)
rootCmd.AddCommand(commands.UpCmd) rootCmd.AddCommand(commands.UpCmd)
+2
View File
@@ -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_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_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_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) // Nodes / agent tokens (admin)
register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false}) register(spec{"containr_nodes_list", "List node agents (admin)", "GET", "/agents", nil, nil, false})
@@ -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;
+2
View File
@@ -11,6 +11,7 @@ import { TemplatesPage } from '@/features/templates/pages/TemplatesPage';
import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage'; import { AuditLogsPage } from '@/features/audit/pages/AuditLogsPage';
import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage'; import { WebhooksPage } from '@/features/webhooks/pages/WebhooksPage';
import { OperationsPage } from '@/features/operations/pages/OperationsPage'; import { OperationsPage } from '@/features/operations/pages/OperationsPage';
import { ActivityPage } from '@/features/activity/pages/ActivityPage';
import { AdminPage } from '@/features/admin/pages/AdminPage'; import { AdminPage } from '@/features/admin/pages/AdminPage';
import { LandingPage } from '@/features/landing/LandingPage'; import { LandingPage } from '@/features/landing/LandingPage';
import { useBranding } from '@/lib/use-branding'; import { useBranding } from '@/lib/use-branding';
@@ -148,6 +149,7 @@ export default function App() {
<Route path="/databases" element={<DatabasesPage />} /> <Route path="/databases" element={<DatabasesPage />} />
<Route path="/ha" element={<HighAvailabilityPage />} /> <Route path="/ha" element={<HighAvailabilityPage />} />
<Route path="/operations" element={<OperationsPage />} /> <Route path="/operations" element={<OperationsPage />} />
<Route path="/activity" element={<ActivityPage />} />
<Route path="/security" element={<SecurityPage />} /> <Route path="/security" element={<SecurityPage />} />
<Route path="/usage" element={<UsagePage />} /> <Route path="/usage" element={<UsagePage />} />
<Route path="/docs" element={<DocsPage />} /> <Route path="/docs" element={<DocsPage />} />
@@ -9,6 +9,7 @@ import {
ChartBar, ChartBar,
CheckCircle2, CheckCircle2,
ChevronsLeft, ChevronsLeft,
Clock,
Container, Container,
Database, Database,
FileText, FileText,
@@ -59,6 +60,7 @@ const navSections: NavSection[] = [
title: 'Operate', title: 'Operate',
items: [ items: [
{ label: 'Operations', href: '/operations', icon: Activity, hint: 'Live jobs & failures' }, { 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: 'Databases', href: '/databases', icon: Database, hint: 'Managed data services' },
{ label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' }, { label: 'High Availability', href: '/ha', icon: ShieldCheck, hint: 'Failover & health' },
{ label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' }, { label: 'Security', href: '/security', icon: Shield, hint: 'Scans & compliance' },
@@ -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<string, typeof Activity> = {
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<string, { icon: typeof Info; cls: string; dot: string }> = {
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 <DemoRestricted feature="Activity" />;
const entries = query.data?.activity ?? [];
return (
<div className="min-h-screen">
<div className="border-b border-[var(--border-subtle)]">
<div className="w-full px-8 py-5">
<h1 className="v-title">Activity<span className="v-cursor">_</span></h1>
<p className="v-mono mt-1.5 text-[11px] text-[var(--text-tertiary)]">
platform events across your visible projects
</p>
</div>
</div>
<div className="px-8 py-6 space-y-4">
<div className="flex items-center gap-2">
{SEVERITIES.map((s) => (
<button
key={s || 'all'}
onClick={() => { setSeverity(s); setPage(1); }}
className={`v-mono text-[10px] uppercase px-3 py-1.5 rounded-md border transition-colors ${
severity === s
? 'border-[var(--accent-primary)] text-[var(--accent-primary)] bg-[var(--accent-primary)]/10'
: 'border-[var(--border-subtle)] text-[var(--text-muted)] hover:text-[var(--text-primary)]'
}`}
>
{s || 'all'}
</button>
))}
{query.isFetching && <Loader2 size={14} className="animate-spin text-[var(--text-muted)]" />}
</div>
<div className="panel p-0 divide-y divide-[var(--border-subtle)]">
{query.isLoading && <p className="px-5 py-8 text-xs text-[var(--text-muted)]">Loading…</p>}
{!query.isLoading && entries.length === 0 && (
<p className="px-5 py-8 text-xs text-[var(--text-muted)]">No events yet — activity appears here as you deploy, configure, and manage services.</p>
)}
{entries.map((e) => <Entry key={e.id} entry={e} />)}
</div>
<div className="flex items-center gap-3">
<button
disabled={page <= 1}
onClick={() => setPage(page - 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
newer
</button>
<button
disabled={!query.data?.has_more}
onClick={() => setPage(page + 1)}
className="v-mono text-[11px] px-3 py-1.5 rounded-md border border-[var(--border-subtle)] text-[var(--text-muted)] disabled:opacity-40 hover:text-[var(--text-primary)]"
>
older
</button>
</div>
</div>
</div>
);
}
function Entry({ entry }: { entry: ActivityEntry }) {
const Cat = CATEGORY_ICONS[entry.category] ?? Activity;
const sev = SEVERITY_STYLE[entry.severity] ?? SEVERITY_STYLE.info;
return (
<div className="flex items-center gap-3 px-5 py-3">
<div className={`w-8 h-8 rounded-md bg-[var(--bg-elevated)] flex items-center justify-center ${sev.cls}`}>
<Cat size={15} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] text-[var(--text-primary)] truncate">{entry.label || entry.action}</span>
<span className={`v-mono text-[9px] uppercase px-1.5 py-0.5 rounded ${sev.cls} bg-[var(--bg-elevated)]`}>{entry.severity}</span>
</div>
<div className="v-mono text-[10px] text-[var(--text-muted)] mt-0.5">
{entry.category} · {entry.action}{entry.user_email ? ` · ${entry.user_email}` : ''}
</div>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)] whitespace-nowrap">{formatRelative(entry.created_at)}</span>
</div>
);
}
@@ -13,6 +13,7 @@ import {
listGitBranches, listGitBranches,
listGitProviders, listGitProviders,
listGitRepositories, listGitRepositories,
listProjectActivity,
listServiceLogs, listServiceLogs,
listServiceVariables, listServiceVariables,
listServicesByProject, listServicesByProject,
@@ -49,12 +50,13 @@ import {
FileUp, FileUp,
} from 'lucide-react'; } 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 }> = [ const viewItems: Array<{ key: WorkspaceView; label: string; icon: typeof LayoutGrid; authOnly?: boolean }> = [
{ key: 'canvas', label: 'Canvas', icon: LayoutGrid }, { key: 'canvas', label: 'Canvas', icon: LayoutGrid },
{ key: 'observability', label: 'Observability', icon: Activity }, { key: 'observability', label: 'Observability', icon: Activity },
{ key: 'logs', label: 'Logs', icon: FileText, authOnly: true }, { key: 'logs', label: 'Logs', icon: FileText, authOnly: true },
{ key: 'activity', label: 'Activity', icon: Clock, authOnly: true },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -640,6 +642,13 @@ export function ProjectWorkspacePage() {
retry: false, 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 runningServices = useMemo(() => services.filter((service) => service.status === 'running').length, [services]);
const serviceHref = (serviceId: string) => const serviceHref = (serviceId: string) =>
isDemoMode isDemoMode
@@ -942,6 +951,42 @@ export function ProjectWorkspacePage() {
</div> </div>
)} )}
{activeView === 'activity' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Clock size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Activity</h2>
<p className="text-sm text-[var(--text-secondary)]">Project timeline — deploys, config, membership</p>
</div>
</div>
<div className="divide-y divide-[var(--border-subtle)] border border-[var(--border-subtle)] rounded-[var(--radius-md)]">
{activityQuery.isLoading ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">Loading…</p>
) : (activityQuery.data?.activity?.length ?? 0) === 0 ? (
<p className="px-4 py-6 text-xs text-[var(--text-muted)]">No project events yet.</p>
) : (
activityQuery.data!.activity.map((e) => (
<div key={e.id} className="flex items-center gap-3 px-4 py-2.5">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${
e.severity === 'error' ? 'bg-[var(--error)]' :
e.severity === 'warning' ? 'bg-[var(--warning)]' :
e.severity === 'success' ? 'bg-[var(--success)]' : 'bg-[var(--accent-primary)]'
}`} />
<div className="flex-1 min-w-0">
<span className="text-[13px] text-[var(--text-primary)]">{e.label || e.action}</span>
<span className="v-mono text-[10px] text-[var(--text-muted)] ml-2">{e.user_email}</span>
</div>
<span className="v-mono text-[10px] text-[var(--text-muted)]">{formatRelative(e.created_at)}</span>
</div>
))
)}
</div>
</div>
)}
{activeView === 'settings' && ( {activeView === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
+114
View File
@@ -3866,6 +3866,111 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/banners/active": {
parameters: { parameters: {
query?: never; query?: never;
@@ -10954,6 +11059,15 @@ export interface components {
ip_address?: string; ip_address?: string;
/** @description Source user agent */ /** @description Source user agent */
user_agent?: string; 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 * Format: date-time
* @description Timestamp when the audit log entry was created * @description Timestamp when the audit log entry was created
+38
View File
@@ -1928,6 +1928,44 @@ export async function getOperations(): Promise<OperationsView> {
return requestJson<OperationsView>('/operations'); return requestJson<OperationsView>('/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<ActivityPage> {
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<ActivityPage>(`/activity${q ? `?${q}` : ''}`);
}
export async function listProjectActivity(projectId: string, page = 1): Promise<ActivityPage> {
return requestJson<ActivityPage>(`/projects/${projectId}/activity?page=${page}&limit=50`);
}
export type Banner = { export type Banner = {
id: string; id: string;
title: string; title: string;
+71
View File
@@ -2620,6 +2620,67 @@ paths:
items: items:
type: object 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: /banners/active:
get: get:
tags: tags:
@@ -7584,6 +7645,16 @@ components:
user_agent: user_agent:
type: string type: string
description: Source user agent 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: created_at:
type: string type: string
format: date-time format: date-time
+2
View File
@@ -84,6 +84,8 @@ webhooks list|create|update|delete|deliveries|test
deliveries are HMAC-signed (X-Containr-Signature) deliveries are HMAC-signed (X-Containr-Signature)
operations live ops view: active deploys, queue depth, operations live ops view: active deploys, queue depth,
24h failures, cron runs, backups 24h failures, cron runs, backups
activity enriched activity feed; --severity/--category/
--resource/--project/--limit filters
banners list|create|update|delete announcement banners (admin); banners list|create|update|delete announcement banners (admin);
active banners print on `auth status` active banners print on `auth status`
invites list|create|revoke team invite links (admin); create invites list|create|revoke team invite links (admin); create