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
+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