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
+18 -3
View File
@@ -2,8 +2,11 @@ import { useEffect, useMemo, useState } from 'react';
import { parse as parseYaml } from 'yaml';
import { GitBranch, Pencil, ExternalLink, Rocket, FolderOpen } from 'lucide-react';
import { Button } from './ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { StackActivityTimeline } from './stack/StackActivityTimeline';
import type { NotificationItem } from '@/components/dashboard/types';
interface StackAnatomyPanelProps {
stackName: string;
@@ -16,6 +19,7 @@ interface StackAnatomyPanelProps {
onApplyUpdate: () => void;
onOpenFiles?: () => void;
canEdit: boolean;
notifications?: NotificationItem[];
}
type SemverBump = 'none' | 'patch' | 'minor' | 'major' | 'unknown';
@@ -232,6 +236,7 @@ export default function StackAnatomyPanel({
onApplyUpdate,
onOpenFiles,
canEdit,
notifications,
}: StackAnatomyPanelProps) {
const anatomy = useMemo(() => parseAnatomy(content), [content]);
const envKeys = useMemo(() => parseEnvKeys(envContent), [envContent]);
@@ -327,8 +332,12 @@ export default function StackAnatomyPanel({
return (
<div className="flex h-full min-h-0 flex-col rounded-xl border border-muted bg-card/40">
<div className="flex items-center justify-between border-b border-muted px-3 py-2 gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">anatomy</span>
<Tabs defaultValue="anatomy" className="flex flex-col h-full min-h-0">
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
<TabsList className="h-7 gap-0.5 bg-transparent border-none p-0">
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
</TabsList>
<div className="flex items-center gap-3">
{onOpenFiles && (
<button
@@ -348,11 +357,15 @@ export default function StackAnatomyPanel({
className="inline-flex items-center gap-1 font-mono text-[10px] uppercase tracking-wide text-stat-subtitle hover:text-brand transition-colors"
>
<Pencil className="h-3 w-3" strokeWidth={1.5} />
edit compose.yaml
edit
</button>
)}
</div>
</div>
<TabsContent value="activity" className="flex-1 min-h-0 overflow-y-auto px-3 mt-0">
<StackActivityTimeline stackName={stackName} liveEvents={notifications?.filter(n => n.stack_name === stackName)} />
</TabsContent>
<TabsContent value="anatomy" className="flex flex-col flex-1 min-h-0 mt-0">
<div className="flex-1 min-h-0 overflow-y-auto px-3">
{!anatomy ? (
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
@@ -511,6 +524,8 @@ export default function StackAnatomyPanel({
)}
</div>
)}
</TabsContent>
</Tabs>
</div>
);
}