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
@@ -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,
});
});
+2
View File
@@ -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);
+23
View File
@@ -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 });
});
+17 -1
View File
@@ -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<string[]> {
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<StackContainerAction, { category: NotificationCategory; pastTense: string }> = {
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),
);
+38 -8
View File
@@ -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, 'id' | 'is_read'>): 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);
+3 -2
View File
@@ -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