Skip to content

Activity feed

The dashboard ships a Live Activity Feed at /activity (sidebar entry under the Workspace group, next to Home). It is a cross-project timeline of audit-log rows, scoped to the user’s accessible projects, polling every five seconds.

Every row written to the audit log shows up: command issuance, task transitions, subscription edits, channel CRUD, user/role changes, schedule edits, MFA enrolments, login attempts, and so on. The feed is the same data the per-project audit page exposes; the difference is breadth (all your projects in one view) and freshness (poll every 5s instead of every 30s).

CallerSees
AdminEvery audit row, including brain-wide rows that have no project_id (first-boot setup, system-wide config events)
Non-admin userOnly rows whose project_id is one the user holds a membership in; brain-wide rows are excluded

A non-admin with zero memberships sees an empty feed.

The page exposes two filters in a sticky filter bar:

  • Project — “All projects” (default) or any single project from the user’s accessible set. Filtering to a project they cannot see is a no-op (returns empty rather than 403, so an enumeration probe gives nothing away).
  • Action prefix — free-form LIKE against the action column. Common prefixes: task., user., agent., auth., subscription..

Filters apply server-side and the polling cycle picks them up immediately.

The page is backed by GET /api/v1/activity:

GET /api/v1/activity?limit=50&project_slug=alpha&action_prefix=task. HTTP/1.1
Authorization: Bearer <session cookie>

Returns:

{
"items": [
{
"id": "0e6c1f3a-12ab-4cde-9876-543210fedcba",
"project_id": "...",
"project_slug": "alpha",
"user_id": "...",
"action": "task.failed",
"target_type": "task",
"target_id": "task-id",
"result": "success",
"outcome": "allow",
"metadata": {"task_name": "send_invoices"},
"source_ip": null,
"occurred_at": "2026-05-12T12:00:00.000000+00:00"
}
],
"next_before_cursor": "2026-05-12T11:30:00.123456+00:00|0e6c1f3a-12ab-4cde-9876-543210fedcba",
"newest_cursor": "2026-05-12T12:05:00.000000+00:00|aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
}

Cursor format is <iso_occurred_at>|<row_id> because the underlying audit_log.id column is uuid4 (random, not time-ordered); ordering by id alone would shuffle rows on every page. The dashboard’s infinite-query refetches the head of the timeline on each 5-second poll and walks next_before_cursor for the Load older button.

source_ip returns null for non-admins regardless of project membership; the per-project audit page is the only surface that returns the field to non-admin callers.

Query parameters:

ParameterNotes
limit1..200, default 50
since_cursorReturn only rows newer than this `
before_cursorReturn only rows older than this `
project_slugConstrain to one project (must be in caller’s accessible set; unknown slugs return empty rather than 403 so enumeration probes give nothing away).
action_prefixLIKE-escaped literal prefix against the action column. % and _ are NOT wildcards.

The endpoint enforces a sliding window of 60 requests / minute per user.id per worker process. Exceeding the limit returns 429 Too Many Requests with a Retry-After: 60 header and a body of {"detail": "activity feed rate limit exceeded (60/min per worker)"}. With Z4J_WORKERS=N and M brain replicas, the cluster-wide effective ceiling is NM60/min per user; the per-worker enforcement is the correct shape for a polling dashboard but is NOT a cluster-wide throttle.

The audit log is the canonical source of truth. A WebSocket push from the brain would add a fan-out boundary (the dashboard hub already exists, but tying the activity feed to it would couple the two surfaces) and a stale-on-disconnect failure mode. Polling at 5 seconds is one extra GET per visible tab; the endpoint is a single indexed query (audit_log ordered by id DESC with a project_id filter), so the cost is negligible. A WebSocket-pushed mode is a candidate for a later minor if operators ask for it.

The activity feed is a READ-ONLY view of the audit log. The endpoint never mutates state, and the data passing through is the same data the per-project audit page already exposes. The new surface area is the cross-project aggregation: a non-admin user with memberships in two projects can now see both project’s audit rows in one view. This is by design and matches the existing per-project audit page’s RBAC (member sees rows; non-member 403s).

The endpoint relies on is_admin + MembershipRepository.list_for_user for scope enforcement; both surfaces are already exercised by the unit tests for the existing audit / projects APIs. The new endpoint adds its own 14-case test suite covering scope enforcement, filter handling, pagination, and the unauthenticated-rejection path.

The feed cannot be disabled per-tenant; it is always available to logged-in users. If you need to hide it from non-admin users entirely, gate the /activity route in your reverse proxy or remove the sidebar entry in a downstream fork of the dashboard build.