mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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>
This commit is contained in:
1 parent
00e69b3733
commit
5eac4a382b
19 files changed
+905
-11
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user