feat(stack): per-stack activity timeline with actor attribution (#852)

* feat(stack): per-stack activity timeline with actor attribution

Adds an Activity tab to the Stack Anatomy panel showing a timestamped
event log for each stack: deploys, restarts, starts, stops, and image
updates, attributed to the user who triggered them or 'system' for
automated actions.

Backend:
- Extends notification_history with actor_username column (idempotent
  migration) and a partial composite index on (node_id, stack_name,
  timestamp DESC) for efficient per-stack lookups.
- NotificationService.dispatchAlert() accepts an optional actor that
  is written to the new column.
- Success-side dispatchAlert calls added after deploy, bulkContainerOp
  (start/stop/restart), and update handlers in routes/stacks.ts so
  user-initiated operations are recorded, not just failures.
- New GET /api/stacks/:stackName/activity?limit&before endpoint with
  stack:read permission gate and cursor-based pagination.

Frontend:
- StackAnatomyPanel grows an Anatomy / Activity tab pair using the
  existing Tabs primitive.
- StackActivityTimeline fetches the initial 50 events, paginates on
  demand, and prepends live events arriving over the existing WS
  notifications stream without duplicates.
- NotificationPanel bell dropdown suppresses user-initiated success
  events (start/stop/restart/deploy/update triggered by a real user),
  keeping the tray focused on alerts and system events.

* docs(stack): add stack activity timeline feature page and internal arch docs

* fix(test): add actor_username to notification-routing history assertions

dispatchAlert now passes actor_username to addNotificationHistory after
the activity timeline PR added the column. Update the two exact-match
assertions that were failing because the expected object shape was missing
this field.
This commit is contained in:
Anso
2026-04-30 19:53:23 -04:00
committed by GitHub
parent a0bf5b5bf5
commit 3e01daf76f
15 changed files with 345 additions and 15 deletions
+13 -1
View File
@@ -89,13 +89,25 @@ function formatRelative(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
const USER_OP_CATEGORIES = new Set([
'deploy_success', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied',
]);
function isUserInitiatedSuccess(n: NotificationItem): boolean {
return n.level === 'info'
&& n.category !== undefined
&& USER_OP_CATEGORIES.has(n.category)
&& n.actor_username != null
&& n.actor_username !== 'system';
}
function applyFilter(
items: NotificationItem[],
filter: NotifFilter,
nodeFilter: NodeFilter,
categoryFilter: CategoryFilter,
): NotificationItem[] {
let result = items;
let result = items.filter(n => !isUserInitiatedSuccess(n));
if (filter === 'unread') result = result.filter((n) => !n.is_read);
else if (filter === 'alerts') result = result.filter((n) => n.level === 'warning' || n.level === 'error');
if (nodeFilter !== NODE_FILTER_ALL) result = result.filter((n) => n.nodeId === nodeFilter);