diff --git a/backend/src/__tests__/notification-routing.test.ts b/backend/src/__tests__/notification-routing.test.ts index 69697604..41eb12a1 100644 --- a/backend/src/__tests__/notification-routing.test.ts +++ b/backend/src/__tests__/notification-routing.test.ts @@ -234,6 +234,7 @@ describe('NotificationService - routing logic', () => { timestamp: expect.any(Number), stack_name: undefined, container_name: undefined, + actor_username: null, }); }); @@ -250,6 +251,7 @@ describe('NotificationService - routing logic', () => { timestamp: expect.any(Number), stack_name: 'my-app', container_name: 'my-app-web-1', + actor_username: null, }); }); diff --git a/backend/src/index.ts b/backend/src/index.ts index d923cec0..c92a8883 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -42,6 +42,7 @@ import { dashboardRouter } from './routes/dashboard'; import { containersRouter, portsRouter } from './routes/containers'; import { nodesRouter } from './routes/nodes'; import { stacksRouter } from './routes/stacks'; +import { stackActivityRouter } from './routes/stackActivity'; // Suppress [DEP0060] DeprecationWarning emitted by http-proxy@1.18.1 which calls // util._extend internally. The warning fires at runtime when createProxyServer() is @@ -117,6 +118,7 @@ app.use('/api/containers', containersRouter); app.use('/api/ports', portsRouter); app.use('/api/dashboard', dashboardRouter); app.use('/api/nodes', nodesRouter); +app.use('/api/stacks', stackActivityRouter); app.use('/api/stacks', stacksRouter); const { server, wss, pilotTunnelWss } = createServer(app); diff --git a/backend/src/routes/stackActivity.ts b/backend/src/routes/stackActivity.ts new file mode 100644 index 00000000..18b48e9c --- /dev/null +++ b/backend/src/routes/stackActivity.ts @@ -0,0 +1,23 @@ +import { Router, type Request, type Response } from 'express'; +import { DatabaseService } from '../services/DatabaseService'; +import { requirePermission } from '../middleware/permissions'; +import { isValidStackName } from '../utils/validation'; + +export const stackActivityRouter = Router(); + +stackActivityRouter.get('/:stackName/activity', (req: Request, res: Response): void => { + const stackName = req.params.stackName as string; + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + const limit = Math.min(parseInt(String(req.query.limit ?? '50'), 10) || 50, 200); + const before = req.query.before ? parseInt(String(req.query.before), 10) : undefined; + if (before !== undefined && isNaN(before)) { + res.status(400).json({ error: 'Invalid before parameter' }); + return; + } + const events = DatabaseService.getInstance().getStackActivity(req.nodeId, stackName, { limit, before }); + res.json({ events }); +}); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 39a6cfcd..1c103464 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -13,7 +13,7 @@ import { GitSourceService, GitSourceError, repoHost as gitRepoHost } from '../se import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement'; import { requirePermission } from '../middleware/permissions'; import { requirePaid, requireAdmin } from '../middleware/tierGates'; -import { NotificationService } from '../services/NotificationService'; +import { NotificationService, type NotificationCategory } from '../services/NotificationService'; import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; @@ -31,6 +31,12 @@ function notifyActionFailure(action: string, stackName: string, error: unknown): .catch(err => console.error('[Stacks] Failed to dispatch failure notification for %s:', sanitizeForLog(stackName), err)); } +function notifyActionSuccess(category: NotificationCategory, message: string, stackName: string, actor: string): void { + NotificationService.getInstance() + .dispatchAlert('info', category, message, { stackName, actor }) + .catch(err => console.error('[Stacks] Failed to dispatch activity for %s:', sanitizeForLog(stackName), err)); +} + async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise { const fsService = FileSystemService.getInstance(nodeId); const stackDir = path.join(fsService.getBaseDir(), stackName); @@ -589,6 +595,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { console.log(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`); if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); res.json({ message: 'Deployed successfully' }); + notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system'); triggerPostDeployScan(stackName, req.nodeId).catch(err => console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), ); @@ -619,6 +626,12 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => { type StackContainerAction = 'restart' | 'stop' | 'start'; +const CONTAINER_ACTION_META: Record = { + restart: { category: 'stack_restarted', pastTense: 'restarted' }, + stop: { category: 'stack_stopped', pastTense: 'stopped' }, + start: { category: 'stack_started', pastTense: 'started' }, +}; + async function bulkContainerOp( req: Request, res: Response, @@ -645,6 +658,8 @@ async function bulkContainerOp( invalidateNodeCaches(req.nodeId); console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${containers.length} containers)`); res.json({ success: true, message: `${titleCase} completed via Engine API.` }); + const { category, pastTense } = CONTAINER_ACTION_META[action]; + notifyActionSuccess(category, `${stackName} ${pastTense}`, stackName, req.user?.username ?? 'system'); } catch (error: unknown) { console.error('[Stacks] %s failed: %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), error); const message = getErrorMessage(error, `Failed to ${action} containers`); @@ -740,6 +755,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { console.log(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`); if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`); res.json({ status: 'Update completed' }); + notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system'); triggerPostDeployScan(stackName, req.nodeId).catch(err => console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), ); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 71d05f23..4b0c4178 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -203,6 +203,7 @@ export interface NotificationHistory { dispatch_error?: string; stack_name?: string; container_name?: string; + actor_username?: string | null; } export interface FleetSnapshot { @@ -517,6 +518,7 @@ export class DatabaseService { this.migrateAgentsAndNotificationsNodeId(); this.migratePolicyEvaluationColumn(); this.migrateNotificationCategory(); + this.migrateNotificationActor(); // Reset the cache once at end of constructor in case any migration // populated it via getGlobalSettings() and a subsequent migration @@ -1238,6 +1240,17 @@ export class DatabaseService { } } + private migrateNotificationActor(): void { + this.tryAddColumn('notification_history', 'actor_username', 'TEXT'); + try { + this.db.prepare( + 'CREATE INDEX IF NOT EXISTS idx_notif_history_node_stack_ts ON notification_history(node_id, stack_name, timestamp DESC) WHERE stack_name IS NOT NULL' + ).run(); + } catch { + // index already present or partial-index syntax unsupported + } + } + // --- Agents --- public getAgents(nodeId: number): Agent[] { @@ -1511,23 +1524,28 @@ export class DatabaseService { // --- Notification History --- - public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] { - const sql = category - ? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?' - : 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?'; - const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit]; - return this.db.prepare(sql).all(...args).map((row: any) => ({ + private mapNotificationRow(row: any): NotificationHistory { + return { ...row, is_read: row.is_read === 1, stack_name: row.stack_name ?? undefined, container_name: row.container_name ?? undefined, category: row.category ?? undefined, - })); + actor_username: row.actor_username ?? null, + }; + } + + public getNotificationHistory(nodeId: number, limit = 50, category?: string): NotificationHistory[] { + const sql = category + ? 'SELECT * FROM notification_history WHERE node_id = ? AND category = ? ORDER BY timestamp DESC LIMIT ?' + : 'SELECT * FROM notification_history WHERE node_id = ? ORDER BY timestamp DESC LIMIT ?'; + const args: (number | string)[] = category ? [nodeId, category, limit] : [nodeId, limit]; + return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any)); } public addNotificationHistory(nodeId: number, notification: Omit): NotificationHistory { const stmt = this.db.prepare( - 'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category) VALUES (?, ?, ?, ?, 0, ?, ?, ?)' + 'INSERT INTO notification_history (node_id, level, message, timestamp, is_read, stack_name, container_name, category, actor_username) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?)' ); const result = stmt.run( nodeId, @@ -1537,6 +1555,7 @@ export class DatabaseService { notification.stack_name ?? null, notification.container_name ?? null, notification.category ?? null, + notification.actor_username ?? null, ); this.db.prepare(` @@ -1555,9 +1574,20 @@ export class DatabaseService { is_read: false, stack_name: notification.stack_name, container_name: notification.container_name, + actor_username: notification.actor_username, }; } + public getStackActivity(nodeId: number, stackName: string, opts: { limit: number; before?: number }): NotificationHistory[] { + const sql = opts.before + ? 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?' + : 'SELECT * FROM notification_history WHERE node_id = ? AND stack_name = ? ORDER BY timestamp DESC LIMIT ?'; + const args: (number | string)[] = opts.before + ? [nodeId, stackName, opts.before, opts.limit] + : [nodeId, stackName, opts.limit]; + return (this.db.prepare(sql).all(...args) as unknown[]).map(row => this.mapNotificationRow(row as any)); + } + public markAllNotificationsRead(nodeId: number): void { const stmt = this.db.prepare('UPDATE notification_history SET is_read = 1 WHERE node_id = ?'); stmt.run(nodeId); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index 68e2039e..aa4ad790 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -105,9 +105,9 @@ export class NotificationService { level: 'info' | 'warning' | 'error', category: NotificationCategory, message: string, - options?: { stackName?: string; containerName?: string }, + options?: { stackName?: string; containerName?: string; actor?: string }, ) { - const { stackName, containerName } = options ?? {}; + const { stackName, containerName, actor } = options ?? {}; // Internal writes use the middleware default so they share a row key // with user-initiated requests; otherwise the UI and monitors split // between different node_id buckets. @@ -119,6 +119,7 @@ export class NotificationService { timestamp: Date.now(), stack_name: stackName, container_name: containerName, + actor_username: actor ?? null, }); // 2. Push to connected browser clients via WebSocket diff --git a/docs/docs.json b/docs/docs.json index 2b10b5e2..68795e8b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -98,6 +98,7 @@ "features/global-search", "features/sidebar", "features/stack-management", + "features/stack-activity", "features/editor", "features/stack-file-explorer", "features/deploy-progress", diff --git a/docs/features/stack-activity.mdx b/docs/features/stack-activity.mdx new file mode 100644 index 00000000..c3bf01a7 --- /dev/null +++ b/docs/features/stack-activity.mdx @@ -0,0 +1,48 @@ +--- +title: Stack Activity +description: Per-stack event log showing deploys, restarts, config changes, and alerts, each attributed to the user or system that triggered them. +--- + +The **Activity** tab inside each stack's panel gives you a timestamped record of everything that happened to that stack: deploys, restarts, starts, stops, and image updates. Each entry shows who triggered the event so you always have operational context. + + + Stack Activity tab showing a restart event attributed to a user + + +## What you'll see + +Each entry in the activity list contains: + +| Field | Description | +|-------|-------------| +| **Icon** | Category of the event (deploy, restart, stop, start, update) | +| **Message** | What happened (e.g. "bazarr restarted") | +| **Attributed to** | `by ` for user-initiated actions, omitted for automated system events | +| **Time** | Relative time since the event (e.g. "just now", "5m ago", "2h ago") | + +Events are grouped by day: **Today**, **Yesterday**, and **Earlier**. + +## Who triggered an event + +Sencho attributes every recorded event to the user who was authenticated when the action was taken. If the action was triggered by an automated process (auto-heal, image update polling, or any other system process) the event is recorded as a system action and the attribution label is omitted from the display. + +## Accessing the Activity tab + +1. Click any stack in the left sidebar to open its panel. +2. Switch to the **Activity** tab in the panel header. + +The tab loads the most recent 50 events immediately and streams new events live as they happen without a page refresh. + +## Pagination + +If a stack has more than 50 recorded events, a **Load more** button appears at the bottom of the list. Each click fetches the next 50 older events. + +## Troubleshooting + +**The Activity tab shows "No activity recorded yet"** + +The activity log is populated by operations performed through Sencho — deploys, restarts, starts, stops, and image updates — since the Sencho instance started tracking them. If you have not performed any of these operations on this stack via Sencho since the feature was enabled, the tab will be empty. Trigger a restart or deploy and the entry appears within a few seconds. + +**Events appear in the bell notification dropdown** + +The notification dropdown shows system alerts and error-level events only. User-initiated operational events (start, stop, restart, deploy, update) appear exclusively in the per-stack Activity tab rather than the global notification tray, keeping the tray focused on conditions that need your attention. diff --git a/docs/images/stack-activity/activity-tab-empty.png b/docs/images/stack-activity/activity-tab-empty.png new file mode 100644 index 00000000..46da433b Binary files /dev/null and b/docs/images/stack-activity/activity-tab-empty.png differ diff --git a/docs/images/stack-activity/activity-tab-populated.png b/docs/images/stack-activity/activity-tab-populated.png new file mode 100644 index 00000000..895607b9 Binary files /dev/null and b/docs/images/stack-activity/activity-tab-populated.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index ce2fa018..628c2941 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -2975,6 +2975,7 @@ export default function EditorLayout() { onOpenGitSource={() => setGitSourceOpen(true)} onApplyUpdate={() => { void updateStack(); }} canEdit={can('stack:edit', 'stack', stackName)} + notifications={notifications} /> )} diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 6e8d1d7f..65330f43 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -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); diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index 65546e83..e747d67d 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -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 (
-
- anatomy + +
+ + Anatomy + Activity +
{onOpenFiles && ( )}
+ + n.stack_name === stackName)} /> + +
{!anatomy ? (
Unable to parse compose.yaml.
@@ -511,6 +524,8 @@ export default function StackAnatomyPanel({ )}
)} +
+
); } diff --git a/frontend/src/components/dashboard/types.ts b/frontend/src/components/dashboard/types.ts index 82daf537..5d67cda7 100644 --- a/frontend/src/components/dashboard/types.ts +++ b/frontend/src/components/dashboard/types.ts @@ -67,6 +67,7 @@ export interface NotificationItem { nodeName?: string; stack_name?: string; container_name?: string; + actor_username?: string | null; } export interface StackStatusEntry { diff --git a/frontend/src/components/stack/StackActivityTimeline.tsx b/frontend/src/components/stack/StackActivityTimeline.tsx new file mode 100644 index 00000000..128b0551 --- /dev/null +++ b/frontend/src/components/stack/StackActivityTimeline.tsx @@ -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 = { + 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 = {}; + 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([]); + const [loading, setLoading] = useState(true); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(true); + const seenIdsRef = useRef(new Set()); + + 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 ( +
+ +
+ ); + } + + if (events.length === 0) { + return ( +
+ + No activity recorded yet +
+ ); + } + + return ( +
+ {groups.map(g => ( +
+
{g.label}
+ {g.events.map(e => { + const Icon = CATEGORY_ICON[e.category ?? ''] ?? Activity; + return ( +
+ +
+ {e.message} + {e.actor_username && e.actor_username !== 'system' && ( + by {e.actor_username} + )} +
+ {formatTimeAgo(e.timestamp)} +
+ ); + })} +
+ ))} + {hasMore && ( + + )} +
+ ); +}