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
+1
View File
@@ -2975,6 +2975,7 @@ export default function EditorLayout() {
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
/>
)}
</div>
+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);
+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>
);
}
@@ -67,6 +67,7 @@ export interface NotificationItem {
nodeName?: string;
stack_name?: string;
container_name?: string;
actor_username?: string | null;
}
export interface StackStatusEntry {
@@ -0,0 +1,178 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Rocket, RefreshCcw, CircleStop, Play, ArrowUp, Activity, Loader2,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { NotificationItem } from '@/components/dashboard/types';
interface ActivityEvent {
id: number;
level: string;
category?: string;
message: string;
timestamp: number;
stack_name?: string;
actor_username?: string | null;
}
interface StackActivityTimelineProps {
stackName: string;
liveEvents?: NotificationItem[];
}
const CATEGORY_ICON: Record<string, LucideIcon> = {
deploy_success: Rocket,
stack_restarted: RefreshCcw,
stack_stopped: CircleStop,
stack_started: Play,
image_update_applied: ArrowUp,
};
const DAY_MS = 86_400_000;
function dayLabel(ts: number): 'Today' | 'Yesterday' | 'Earlier' {
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayMs = todayStart.getTime();
if (ts >= todayMs) return 'Today';
if (ts >= todayMs - DAY_MS) return 'Yesterday';
return 'Earlier';
}
function groupEvents(events: ActivityEvent[]): { label: string; events: ActivityEvent[] }[] {
const groups: Record<string, ActivityEvent[]> = {};
const order: string[] = [];
for (const e of events) {
const label = dayLabel(e.timestamp);
if (!groups[label]) { groups[label] = []; order.push(label); }
groups[label].push(e);
}
return order.map(label => ({ label, events: groups[label] }));
}
export function StackActivityTimeline({ stackName, liveEvents }: StackActivityTimelineProps) {
const [events, setEvents] = useState<ActivityEvent[]>([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true);
const seenIdsRef = useRef(new Set<number>());
const mergeEvents = useCallback((incoming: ActivityEvent[]) => {
setEvents(prev => {
const next = [...prev];
let added = false;
for (const e of incoming) {
if (seenIdsRef.current.has(e.id)) continue;
seenIdsRef.current.add(e.id);
next.push(e);
added = true;
}
if (!added) return prev;
next.sort((a, b) => b.timestamp - a.timestamp);
return next;
});
}, []);
useEffect(() => {
let cancelled = false;
setLoading(true);
seenIdsRef.current = new Set();
setEvents([]);
setHasMore(true);
apiFetch(`/stacks/${stackName}/activity?limit=50`)
.then(r => (r.ok ? r.json() : Promise.reject()))
.then((data: { events: ActivityEvent[] }) => {
if (cancelled) return;
setHasMore(data.events.length === 50);
data.events.forEach(e => seenIdsRef.current.add(e.id));
setEvents(data.events);
})
.catch(() => { if (!cancelled) setEvents([]); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [stackName]);
// liveEvents is pre-filtered by stack_name in the parent
useEffect(() => {
if (!liveEvents || liveEvents.length === 0) return;
mergeEvents(liveEvents as ActivityEvent[]);
}, [liveEvents, mergeEvents]);
const loadMore = useCallback(async () => {
const oldest = events[events.length - 1]?.timestamp;
if (!oldest) return;
setLoadingMore(true);
try {
const r = await apiFetch(`/stacks/${stackName}/activity?limit=50&before=${oldest}`);
if (!r.ok) return;
const data: { events: ActivityEvent[] } = await r.json();
setHasMore(data.events.length === 50);
mergeEvents(data.events);
} catch {
toast.error('Failed to load more activity');
} finally {
setLoadingMore(false);
}
}, [events, stackName, mergeEvents]);
const groups = useMemo(() => groupEvents(events), [events]);
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
</div>
);
}
if (events.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-8 gap-2">
<Activity className="w-5 h-5 text-muted-foreground/40" />
<span className="font-mono text-[11px] text-muted-foreground">No activity recorded yet</span>
</div>
);
}
return (
<div className="flex flex-col gap-3 py-3">
{groups.map(g => (
<div key={g.label}>
<div className="font-mono text-[9px] uppercase tracking-[0.18em] text-stat-subtitle mb-1.5 px-1">{g.label}</div>
{g.events.map(e => {
const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity;
return (
<div key={e.id} className="flex items-start gap-2 py-1.5 px-1 rounded-md hover:bg-glass-highlight/30 transition-colors">
<Icon className="w-3 h-3 mt-0.5 shrink-0 text-brand/70" strokeWidth={1.5} />
<div className="flex-1 min-w-0">
<span className="font-mono text-[11px] text-foreground/90">{e.message}</span>
{e.actor_username && e.actor_username !== 'system' && (
<span className="ml-1.5 font-mono text-[10px] text-stat-subtitle">by {e.actor_username}</span>
)}
</div>
<span className="font-mono text-[10px] text-stat-subtitle shrink-0">{formatTimeAgo(e.timestamp)}</span>
</div>
);
})}
</div>
))}
{hasMore && (
<Button
variant="ghost"
size="sm"
className="w-full h-7 font-mono text-[10px] text-muted-foreground"
onClick={() => void loadMore()}
disabled={loadingMore}
>
{loadingMore ? <Loader2 className="w-3 h-3 animate-spin" /> : 'Load more'}
</Button>
)}
</div>
);
}